Backend

API Rate Limiting Implementation

Mayur Dabhi
Mayur Dabhi
April 18, 2026
14 min read

Someone writes a sync script against your API, forgets a sleep() in the retry loop, and suddenly one API key is sending hundreds of requests a second. Your database CPU climbs, response times go up for every other customer, and the first you hear of it is a support ticket. Or it's a bot hammering /login with a password list. Either way, nothing in your stack was saying "that's enough for now."

That's the job of rate limiting: capping how many requests a client can make in a given time window. This post covers the common algorithms, the HTTP status code and headers clients expect, working implementations in Express and Laravel, how to share counters across instances with Redis, and the details that tend to bite once it's running in production.

How the Big APIs Do It

GitHub's REST API allows 60 requests/hour unauthenticated and 5,000/hour with a token. Stripe allows roughly 100 operations per second in live mode. The numbers differ, but the idea is the same: published limits let them plan capacity, and let clients know exactly how hard they can push.

Rate Limiting Algorithms Explained

Every algorithm here trades off three things: how accurate the count is, how much memory it costs per client, and how much burstiness it tolerates.

Fixed Window Simple, window boundary burst problem Sliding Window Log Precise, high memory per user Sliding Window Counter 7 3 Approx. sliding, low memory Token Bucket +1/s Allows bursts, smooth refill Choose based on: accuracy needs vs memory budget vs burst tolerance Leaky Bucket constant rate Queue-based, guarantees constant output rate, drops excess

Rate Limiting Algorithm Comparison

Fixed Window Counter

Split time into fixed windows (say, every minute) and keep one counter per window. It's fast and cheap, but it has a boundary burst problem. A client can send N requests in the last second of one window and N more in the first second of the next, so they get 2N requests through in about two seconds.

Sliding Window Log

Keep a timestamp for every request each client makes. On a new request, drop the timestamps older than the window and count what's left. The count is exact, but memory grows with traffic, which hurts on busy APIs.

Sliding Window Counter (Recommended)

Keep two counters, one for the current window and one for the previous, and estimate the rate by weighting the previous window by how much of it still overlaps. You get something close to a true sliding window with constant memory per client. For most APIs this is the one I'd reach for first.

Token Bucket

Each client gets a bucket that holds up to N tokens, refilled at a steady rate. Every request takes a token. Clients can burst until the bucket is empty, but the long-term average can't exceed the refill rate. It suits APIs where short spikes are fine (a mobile app syncing on launch, for example).

HTTP Rate Limit Response Standards

Clients can only back off properly if your responses follow the usual conventions. The status code is 429 Too Many Requests, and the headers below tell the client where it stands.

Header Meaning Example Value
X-RateLimit-Limit Max requests allowed in the window 100
X-RateLimit-Remaining Requests remaining in the current window 73
X-RateLimit-Reset Unix timestamp when the window resets 1713456000
Retry-After Seconds to wait before retrying (on 429) 47
RateLimit-Policy (IETF draft) Machine-readable policy description 100;w=60
Common Mistake

Don't return 403 Forbidden for rate limiting. It tells the client it isn't allowed at all, and well-behaved clients won't retry. Use 429 Too Many Requests (defined in RFC 6585) with a Retry-After header so they know the block is temporary.

Implementing Rate Limiting in Node.js / Express

In Express, most people use express-rate-limit. It's a single middleware with pluggable stores, so you can start in memory and move to Redis later without rewriting your routes.

1

Install Dependencies

Install express-rate-limit for the middleware and rate-limit-redis for distributed storage.

Terminal
npm install express-rate-limit rate-limit-redis ioredis
2

Basic Rate Limiter Middleware

Define a few limiters up front: a general one, a strict one for login, and a very strict one for password resets.

middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');

// General API limiter: 100 requests per 15 minutes
const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes in milliseconds
  max: 100,                  // Max requests per window
  standardHeaders: true,     // Send the standard RateLimit-* headers
  legacyHeaders: false,      // Disable X-RateLimit-* legacy headers

  // Custom key generator: rate limit by API key or IP
  keyGenerator: (req) => {
    return req.headers['x-api-key'] || req.ip;
  },

  // Custom response when limit is exceeded
  handler: (req, res, next, options) => {
    res.status(429).json({
      error: 'Too Many Requests',
      message: `You have exceeded the ${options.max} requests per ${options.windowMs / 60000} minutes limit.`,
      retryAfter: Math.ceil(options.windowMs / 1000),
    });
  },

  // Skip rate limiting for trusted IPs (e.g., internal services)
  skip: (req) => {
    const trustedIPs = ['127.0.0.1', '::1'];
    return trustedIPs.includes(req.ip);
  },
});

// Strict limiter for auth endpoints: 10 requests per 15 minutes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  standardHeaders: true,
  legacyHeaders: false,
  message: {
    error: 'Too Many Requests',
    message: 'Too many login attempts. Please try again later.',
  },
});

// Very strict: password reset, 3 per hour
const passwordResetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 3,
  standardHeaders: true,
  legacyHeaders: false,
});

module.exports = { generalLimiter, authLimiter, passwordResetLimiter };
3

Apply to Routes

Mount the general limiter on /api/ and stack the stricter ones on individual routes.

app.js
const express = require('express');
const { generalLimiter, authLimiter, passwordResetLimiter } = require('./middleware/rateLimiter');

const app = express();
app.use(express.json());

// Trust proxy headers if behind Nginx/load balancer
// This makes req.ip reflect the real client IP, not the proxy
app.set('trust proxy', 1);

// Apply general limiter to all API routes
app.use('/api/', generalLimiter);

// Stricter limits on sensitive auth routes
app.post('/api/auth/login',    authLimiter,          loginController);
app.post('/api/auth/register', authLimiter,          registerController);
app.post('/api/auth/forgot',   passwordResetLimiter, forgotPasswordController);

// Public routes with higher limits (or no limit)
app.get('/api/public/status', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(3000);
Trust Proxy Setting

Behind Nginx or a load balancer, set app.set('trust proxy', 1). Without it, req.ip is the proxy's address, every user lands in the same bucket, and the whole site gets throttled at once. Recent versions of express-rate-limit (v7+) catch this for you: if they see an X-Forwarded-For header while trust proxy is off, they log a ValidationError with the code ERR_ERL_UNEXPECTED_X_FORWARDED_FOR. If you see that in your logs, don't silence it. The 1 means "trust one hop", so set it to match how many proxies actually sit in front of the app; setting it to true lets clients spoof their IP with a forged header.

Redis-Backed Distributed Rate Limiting

The default memory store breaks as soon as you run more than one process. With 3 Node.js instances (or 3 PM2 cluster workers), each keeps its own counter, so the real limit is 3× what you configured. You need a shared store, and Redis is the usual choice.

Clients Load Balancer Nginx Node.js #1 Rate Limiter Node.js #2 Rate Limiter Node.js #3 Rate Limiter Redis Shared Counter user:192.168.1.1 → 43 All instances share one counter in Redis → consistent limits

Distributed Rate Limiting with Redis as Shared Store

middleware/redisRateLimiter.js
const rateLimit = require('express-rate-limit');
const RedisStore  = require('rate-limit-redis');
const Redis       = require('ioredis');

// Create Redis client
const redisClient = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  password: process.env.REDIS_PASSWORD,
  // Reconnect on failure
  retryStrategy: (times) => Math.min(times * 50, 2000),
});

redisClient.on('error', (err) => {
  console.error('Redis error:', err);
});

// Redis-backed rate limiter
const redisRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,

  // Use Redis store for distributed environments
  store: new RedisStore({
    sendCommand: (...args) => redisClient.call(...args),
    // Key prefix to avoid collisions with other Redis keys
    prefix: 'rl:',
  }),

  keyGenerator: (req) => {
    // Rate limit by API key if present, fall back to IP
    return req.headers['x-api-key']
      ? `apikey:${req.headers['x-api-key']}`
      : `ip:${req.ip}`;
  },
});

module.exports = { redisRateLimiter, redisClient };

Sliding Window with Raw Redis Commands

If you want control over the algorithm itself, a sliding window counter is only a few Redis commands. INCR is atomic, so two instances incrementing the same key at the same moment won't lose a count:

utils/slidingWindowRateLimit.js
const Redis = require('ioredis');
const redis = new Redis();

/**
 * Sliding window rate limiter using Redis.
 * Uses two keys: current window counter and previous window counter.
 * Approximates a true sliding window with O(1) memory per user.
 *
 * @param {string} key      - Unique identifier (user ID, API key, IP)
 * @param {number} limit    - Max requests allowed per window
 * @param {number} windowMs - Window duration in milliseconds
 * @returns {{ allowed: boolean, remaining: number, resetAt: number }}
 */
async function slidingWindowRateLimit(key, limit, windowMs) {
  const now        = Date.now();
  const windowSec  = Math.floor(windowMs / 1000);
  const currentSlot = Math.floor(now / windowMs);
  const prevSlot    = currentSlot - 1;

  const currentKey = `rl:sw:${key}:${currentSlot}`;
  const prevKey    = `rl:sw:${key}:${prevSlot}`;

  // Atomically increment current window counter
  const pipeline = redis.pipeline();
  pipeline.incr(currentKey);
  pipeline.expire(currentKey, windowSec * 2); // TTL: 2 windows
  pipeline.get(prevKey);
  const [[, current], , [, prev]] = await pipeline.exec();

  const currentCount = parseInt(current, 10);
  const prevCount    = parseInt(prev, 10) || 0;

  // Calculate weight of previous window's requests in the current window
  const windowProgress    = (now % windowMs) / windowMs;
  const prevWindowWeight  = 1 - windowProgress;
  const estimatedCount    = Math.ceil(prevCount * prevWindowWeight) + currentCount;

  const allowed   = estimatedCount <= limit;
  const remaining = Math.max(0, limit - estimatedCount);
  const resetAt   = (currentSlot + 1) * windowMs;

  return { allowed, remaining, resetAt, estimatedCount };
}

// Express middleware wrapper
function createSlidingWindowMiddleware(limit, windowMs) {
  return async (req, res, next) => {
    const key    = req.headers['x-api-key'] || req.ip;
    const result = await slidingWindowRateLimit(key, limit, windowMs);

    // Always set headers
    res.set('X-RateLimit-Limit',     limit);
    res.set('X-RateLimit-Remaining', result.remaining);
    res.set('X-RateLimit-Reset',     Math.floor(result.resetAt / 1000));

    if (!result.allowed) {
      const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);
      res.set('Retry-After', retryAfter);
      return res.status(429).json({
        error: 'Too Many Requests',
        retryAfter,
        limit,
        resetAt: new Date(result.resetAt).toISOString(),
      });
    }

    next();
  };
}

module.exports = { slidingWindowRateLimit, createSlidingWindowMiddleware };
Decide What Happens When Redis Is Down

The middleware above awaits Redis without a try/catch. In Express 4, a rejected promise inside middleware isn't passed to your error handler, so if Redis goes away the request just hangs until it times out, and you get UnhandledPromiseRejection warnings in the logs. Wrap the call and pick a policy on purpose: fail open (call next() and log it) for general traffic, fail closed (return 429 or 503) for login and password reset.

Rate Limiting in Laravel

Laravel ships with the ThrottleRequests middleware and, since Laravel 8, the RateLimiter facade for named limiters with dynamic keys. For most apps you won't need a package.

Apply Laravel's built-in throttle middleware directly in routes:

// routes/api.php

use Illuminate\Support\Facades\Route;

// 60 requests per minute (format: max,minutes)
Route::middleware('throttle:60,1')->group(function () {
    Route::get('/users', [UserController::class, 'index']);
    Route::get('/posts', [PostController::class, 'index']);
});

// 10 requests per 15 minutes for auth endpoints
Route::middleware('throttle:10,15')->group(function () {
    Route::post('/login',    [AuthController::class, 'login']);
    Route::post('/register', [AuthController::class, 'register']);
});

// Use a named rate limiter
Route::middleware('throttle:api')->group(function () {
    Route::get('/profile', [ProfileController::class, 'show']);
});

Define custom rate limiters in App\Providers\RouteServiceProvider. On Laravel 11 and later that provider is gone by default, so put the same RateLimiter::for() calls in the boot() method of AppServiceProvider:

// app/Providers/RouteServiceProvider.php

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

protected function configureRateLimiting(): void
{
    // Default API limiter (authenticated users get higher limits)
    RateLimiter::for('api', function (Request $request) {
        return $request->user()
            ? Limit::perMinute(60)->by($request->user()->id)
            : Limit::perMinute(20)->by($request->ip());
    });

    // Per-plan limits for SaaS tiering
    RateLimiter::for('api-tiered', function (Request $request) {
        $user = $request->user();

        $limits = [
            'free'       => 60,
            'pro'        => 500,
            'enterprise' => 5000,
        ];

        $max = $limits[$user?->plan ?? 'free'];

        return Limit::perHour($max)
            ->by($user?->id ?? $request->ip())
            ->response(function (Request $request, array $headers) {
                return response()->json([
                    'error'      => 'Too Many Requests',
                    'message'    => 'You have exceeded your plan limit.',
                    'upgrade_at' => 'https://mayurdabhi.com/pricing',
                ], 429, $headers);
            });
    });

    // Strict limiter for password resets
    RateLimiter::for('password-reset', function (Request $request) {
        return [
            Limit::perHour(3)->by($request->ip()),
            Limit::perHour(3)->by($request->input('email')),
        ];
    });
}

Configure Redis as the cache store for rate limiting in config/cache.php:

// .env
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

// config/cache.php
'redis' => [
    'driver' => 'redis',
    'connection' => 'cache',
    'lock_connection' => 'default',
],

// config/database.php: Redis connections
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),
    'default' => [
        'url'      => env('REDIS_URL'),
        'host'     => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port'     => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_DB', '0'),
    ],
    'cache' => [
        'url'      => env('REDIS_URL'),
        'host'     => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port'     => env('REDIS_PORT', '6379'),
        'database' => env('REDIS_CACHE_DB', '1'),
    ],
],

Custom Rate Limit Middleware in Laravel

Sometimes the named limiters aren't enough, for example when you want different limits per HTTP method on every endpoint. Then a small custom middleware is easier to read:

app/Http/Middleware/ApiRateLimiter.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\Response;

class ApiRateLimiter
{
    public function handle(Request $request, Closure $next): Response
    {
        // Build a unique key from API key + endpoint + method
        $apiKey = $request->header('X-API-Key', $request->ip());
        $key    = "rl:{$apiKey}:{$request->method()}:{$request->path()}";

        // Determine limit based on request method
        $maxAttempts = match ($request->method()) {
            'GET'    => 120,  // Read-heavy
            'POST'   => 30,
            'PUT',
            'PATCH'  => 30,
            'DELETE' => 10,   // Destructive ops, very strict
            default  => 60,
        };

        if (RateLimiter::tooManyAttempts($key, $maxAttempts)) {
            $seconds = RateLimiter::availableIn($key);

            return response()->json([
                'error'       => 'Too Many Requests',
                'retry_after' => $seconds,
                'message'     => "Limit of {$maxAttempts} requests per minute exceeded.",
            ], 429, [
                'X-RateLimit-Limit'     => $maxAttempts,
                'X-RateLimit-Remaining' => 0,
                'X-RateLimit-Reset'     => now()->addSeconds($seconds)->timestamp,
                'Retry-After'           => $seconds,
            ]);
        }

        RateLimiter::hit($key, 60); // Decay after 60 seconds

        $remaining = $maxAttempts - RateLimiter::attempts($key);

        $response = $next($request);

        // Append rate limit headers to every response
        return $response->withHeaders([
            'X-RateLimit-Limit'     => $maxAttempts,
            'X-RateLimit-Remaining' => max(0, $remaining),
        ]);
    }
}

Advanced Strategies

Tiered Rate Limiting

Once you have paying customers, one limit for everyone stops making sense. Here the limit comes from the plan attached to the API key:

Tiered Rate Limiting (Node.js)
// Plan-based limits
const PLAN_LIMITS = {
  free:       { requests: 100,  windowMs: 60 * 60 * 1000 },  // 100/hour
  starter:    { requests: 1000, windowMs: 60 * 60 * 1000 },  // 1k/hour
  pro:        { requests: 10000,windowMs: 60 * 60 * 1000 },  // 10k/hour
  enterprise: { requests: 100000,windowMs: 60 * 60 * 1000 }, // 100k/hour
};

async function tieredRateLimiter(req, res, next) {
  // Look up user's plan from database/cache
  const apiKey = req.headers['x-api-key'];
  const user   = apiKey ? await getUserByApiKey(apiKey) : null;
  const plan   = user?.plan ?? 'free';

  const { requests, windowMs } = PLAN_LIMITS[plan];
  const key    = apiKey ? `plan:${apiKey}` : `ip:${req.ip}`;
  const result = await slidingWindowRateLimit(key, requests, windowMs);

  res.set('X-RateLimit-Limit',     requests);
  res.set('X-RateLimit-Remaining', result.remaining);
  res.set('X-RateLimit-Plan',      plan);

  if (!result.allowed) {
    return res.status(429).json({
      error:   'Rate limit exceeded',
      plan,
      limit:   requests,
      upgrade: plan !== 'enterprise' ? 'https://example.com/pricing' : null,
    });
  }

  next();
}

Endpoint-Specific Limits

A health check costs almost nothing. A search that hits five tables, or a CSV export, can cost a lot more. Set limits by what the endpoint costs you, not just who's calling:

Per-Endpoint Limits
const rateLimit = require('express-rate-limit');

// Cheap: status and health checks, very permissive
const statusLimiter = rateLimit({ windowMs: 60000, max: 300 });

// Moderate: standard CRUD
const crudLimiter = rateLimit({ windowMs: 60000, max: 60 });

// Expensive: search, reports, exports
const searchLimiter = rateLimit({ windowMs: 60000, max: 10 });

// Very expensive: bulk operations
const bulkLimiter = rateLimit({ windowMs: 60000, max: 3 });

app.get('/api/health',         statusLimiter, healthController);
app.get('/api/users',          crudLimiter,   userListController);
app.get('/api/search',         searchLimiter, searchController);
app.post('/api/reports/generate', searchLimiter, reportController);
app.post('/api/users/bulk-import', bulkLimiter, bulkImportController);

Rate Limiting vs Other Defenses

Defense Protects Against Works With Rate Limiting?
Rate Limiting Abuse, brute force, resource exhaustion N/A
Input Validation Injection, malformed data Yes, they cover different problems
Authentication Unauthorized access Yes, it identifies users for per-user limits
WAF / Firewall DDoS, bot traffic, known bad IPs Yes, it filters traffic before it reaches your app
CAPTCHA Automated bots Yes, shown after repeated failures
IP Blocklist Known malicious IPs Yes, used together

Testing Your Rate Limiter

Rate limiters fail quietly. A wrong key or a missing trust proxy setting won't throw errors; the limits just won't apply the way you think they do. A few tests catch that:

tests/rateLimiter.test.js (Jest)
const request = require('supertest');
const app     = require('../app');

describe('Rate Limiter', () => {
  it('allows requests within the limit', async () => {
    const res = await request(app)
      .get('/api/users')
      .set('X-API-Key', 'test-key-1');

    expect(res.status).toBe(200);
    expect(res.headers['x-ratelimit-limit']).toBeDefined();
    expect(res.headers['x-ratelimit-remaining']).toBeDefined();
  });

  it('returns 429 after exceeding the limit', async () => {
    const apiKey = 'test-key-abuse';

    // Exhaust the limit
    for (let i = 0; i < 10; i++) {
      await request(app)
        .post('/api/auth/login')
        .set('X-API-Key', apiKey)
        .send({ email: 'a@a.com', password: 'wrong' });
    }

    // This one should be blocked
    const res = await request(app)
      .post('/api/auth/login')
      .set('X-API-Key', apiKey)
      .send({ email: 'a@a.com', password: 'wrong' });

    expect(res.status).toBe(429);
    expect(res.body.error).toBe('Too Many Requests');
    expect(res.headers['retry-after']).toBeDefined();
  });

  it('resets after the window expires', async () => {
    // Use fake timers to simulate window expiry
    jest.useFakeTimers();
    const apiKey = 'test-key-reset';

    for (let i = 0; i < 10; i++) {
      await request(app)
        .post('/api/auth/login')
        .set('X-API-Key', apiKey)
        .send({});
    }

    // Advance time by 15 minutes + 1 second
    jest.advanceTimersByTime(15 * 60 * 1000 + 1000);

    const res = await request(app)
      .post('/api/auth/login')
      .set('X-API-Key', apiKey)
      .send({});

    expect(res.status).not.toBe(429);
    jest.useRealTimers();
  });
});

One catch with the window-reset test: fake timers only move Node's clock. If the limiter uses Redis, the key TTLs expire on Redis's own clock, so that test will still see 429. For Redis-backed limiters, use a short window in tests (or flush the test keys) instead. For a quick manual check, curl is enough:

Manual Testing with curl
# Send 15 requests in a loop and watch the headers
for i in $(seq 1 15); do
  echo "Request $i:"
  curl -s -o /dev/null -w "Status: %{http_code} | Headers: %{response_code}\n" \
    -H "X-API-Key: my-test-key" \
    http://localhost:3000/api/users
done

# Check specific headers from a single request
curl -I -H "X-API-Key: my-test-key" http://localhost:3000/api/users | grep -i "ratelimit\|retry"

What to Check Before Going to Production

Most rate limiting bugs I run into aren't algorithm problems. They're configuration problems that only show up under real traffic:

Before You Ship

  • More than one process? Use Redis or another shared store. PM2 cluster mode counts too.
  • Check req.ip in production by logging it once. If every request shows your load balancer's address, trust proxy is wrong.
  • Watch out for shared IPs. Offices, universities and mobile carriers put many users behind one IP (CGNAT). Pure IP limits will block them together, so key by user or API key wherever you can.
  • Pick a Redis failure policy: fail open for general reads, fail closed for auth endpoints.
  • Return 429 with Retry-After, and document your limits so client authors can add back-off.
  • Log and graph 429s. A sudden jump is either an attack or a limit set too low for a legitimate client, and you'll want to know which.
  • Exempt internal services by service token rather than IP, so a cron job doesn't throttle your own API.

When an app-level limiter isn't the right tool

An application rate limiter still accepts the connection, parses the request and makes a Redis call before rejecting it. That's fine for brute force on login and for clients that get carried away. It won't save you from a real DDoS: by the time Node or PHP sees the traffic, your servers are already paying for it. Handle volumetric attacks at the edge with Nginx limit_req, a CDN or a WAF, and keep the app limiter for per-user and per-plan rules that need to know who the caller is.

I'd also skip the custom Redis sliding window unless you actually need it. express-rate-limit with a Redis store, or Laravel's throttle middleware on a Redis cache, covers most APIs. Hand-rolled limiters are one more thing to test and keep working, so write one only when the built-in options can't express the rule you need.

API Rate Limiting Security Node.js Laravel Redis Backend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.