Rate Limiting an API Correctly, Common Mistakes
Rate limiting looks simple on the surface — count requests, block when a threshold is exceeded — but several common implementation mistakes either leave the limit trivially bypassable or end up blocking legitimate users while barely slowing down an actual attacker.
The Problem
A rate limit is implemented, but abuse continues largely unaffected, or legitimate users start hitting the limit unexpectedly during normal use — sometimes both problems appear in the same implementation simultaneously.
Why It Happens
Several specific, common mistakes account for most rate limiting problems:
- Rate limiting by IP address alone, which is trivially bypassed by anyone using multiple IPs (a botnet, a proxy pool, or even just switching networks), while simultaneously punishing legitimate users who happen to share an IP — common with corporate networks or mobile carriers using NAT, where many genuine users appear to come from the same address
- Using a fixed time-window counter (like "100 requests per hour, resetting on the hour") that allows a burst of double the intended rate right at the window boundary — a client can send 100 requests in the final second of one window and another 100 in the first second of the next, effectively getting 200 requests in two seconds while technically staying within both individual windows
- Applying the same rate limit uniformly across every endpoint, when a login endpoint (which should be tightly limited to prevent credential stuffing) and a low-risk read-only endpoint have very different appropriate limits
- Rate limiting only at the API layer while leaving expensive downstream operations (a slow database query, a third-party API call) unprotected, so even requests within the rate limit can still overwhelm a downstream dependency if each individual request is expensive enough
The Fix
Rate limit by a combination of identifiers rather than IP address alone — typically an authenticated user ID when available, falling back to IP only for unauthenticated requests, and even then combined with something else where practical (like an API key, if one is required):
const key = userId ? `user:${userId}` : `ip:${clientIp}`;
Use a sliding window algorithm instead of a fixed window, which correctly accounts for request distribution across the boundary between windows rather than allowing a burst exploit at the edges:
-- Sliding window log approach using Redis sorted sets
ZADD rate_limit:user:123 <timestamp> <unique_request_id>
ZREMRANGEBYSCORE rate_limit:user:123 0 <timestamp_minus_window>
ZCARD rate_limit:user:123
This pattern (common with Redis-backed implementations) removes entries older than the current window on each check, then counts what remains — correctly enforcing the limit as a true rolling window rather than a fixed, resettable bucket.
Set different limits for different endpoints based on their actual risk and cost profile, rather than a single blanket rate across the entire API:
const limits = {
'/api/login': { max: 5, window: '15m' },
'/api/search': { max: 100, window: '1m' },
'/api/users': { max: 1000, window: '1h' },
};
Sensitive, high-risk endpoints (login, password reset, anything that could be used for credential stuffing or enumeration) deserve much tighter limits than general read endpoints, even if that means the rest of the API can be more generous by comparison.
Add rate limiting or circuit-breaking around expensive downstream operations specifically, not just at the API's front door — an endpoint that makes a slow third-party API call or an expensive database aggregation needs its own protection against being called too frequently in parallel, independent of whether the overall request rate to your API is technically within limits.
Return clear, standard rate limit headers so legitimate clients can adapt their behavior gracefully rather than just receiving an opaque error:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1706450400
Still Not Working?
If rate limiting is correctly implemented but abuse continues, check whether the limiting is actually being applied at a layer an attacker can bypass — for example, rate limiting implemented only in application code that runs behind a CDN or load balancer which itself retries failed requests automatically, effectively multiplying the actual request volume beyond what your application-level counter observes. In high-value scenarios, layering rate limiting at multiple levels (CDN/WAF level and application level) provides defense in depth that a single layer alone can't guarantee, since no single point in the request path has complete visibility into every possible bypass vector.