API Rate Limiting Implementation
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.
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.
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 |
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.
Install Dependencies
Install express-rate-limit for the middleware and rate-limit-redis for distributed storage.
npm install express-rate-limit rate-limit-redis ioredis
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.
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 };
Apply to Routes
Mount the general limiter on /api/ and stack the stricter ones on individual routes.
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);
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.
Distributed Rate Limiting with Redis as Shared Store
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:
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 };
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:
<?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:
// 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:
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:
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:
# 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.ipin production by logging it once. If every request shows your load balancer's address,trust proxyis 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.