An API sets a rate limit of 10 requests per minute, tests it by sending 10 requests and confirming the 11th gets rejected, and considers the job done. Then a client sends 10 requests at the very end of one minute and another 10 at the very start of the next, twenty requests inside a three-second window, and every single one sails through, because both batches technically landed in different, separately-counted minutes. The limit was never violated on paper. The server just got hit with double its intended load anyway.
This is the classic boundary problem with the simplest, most commonly implemented rate limiting approach, and it’s worth seeing with real numbers rather than taking on faith. Simulating this exact scenario, ten requests at the 59-second mark of one window and ten more at the 1-second mark of the next, a naive fixed window limiter allows all twenty. A properly implemented sliding window or token bucket limiter, run against the identical burst, allows only ten. Same nominal limit, same traffic pattern, genuinely different real-world protection.
Why Rate Limiting Exists in the First Place
Rate limiting protects against both malicious abuse and ordinary, accidental overload, a client with a runaway retry loop, a legitimate integration sending more traffic than expected during a spike, or a deliberate attempt to overwhelm an endpoint, all of which can degrade service for every other client sharing the same infrastructure if left unchecked. This matters as much for REST APIs, as covered in this site’s REST API design guide, as it does for GraphQL, where a single request’s cost can vary enormously and a naive per-request limit provides considerably weaker protection, a problem covered in depth in the GraphQL query complexity calculator on this site.
Which specific algorithm enforces that limit changes the actual protection you get in practice, not just the implementation complexity, which is exactly what the numbers above demonstrate.
Fixed Window: Simple, and Simply Wrong at the Edges
Fixed window counting divides time into discrete, non-overlapping intervals, say, every 60-second block starting at the top of each minute, and counts requests within each block independently, resetting to zero the moment a new block begins. It’s the simplest algorithm to implement, a single counter per client per window, incremented on each request and reset on a timer, which is exactly why it’s often the first approach anyone reaches for.
The boundary problem demonstrated above is structural, not an edge case that only shows up under contrived conditions. Any client that understands the window reset timing, which requires no special insight, just noticing that limits reset predictably, can burst up to double the nominal limit by timing requests to straddle a boundary deliberately. For a rate limit meant to actually cap worst-case load on a server, this is a meaningful, exploitable gap, not a theoretical footnote.
// Fixed window: simple, but allows boundary bursts
const windowKey = Math.floor(Date.now() / windowMs);
counts[windowKey] = (counts[windowKey] || 0) + 1;
const allowed = counts[windowKey] <= limit;
Sliding Window Log: Accurate, at a Real Memory Cost
A sliding window log tracks the actual timestamp of every request within the trailing window period, rather than bucketing requests into fixed, discrete blocks. To check a new request, it discards any stored timestamps older than the window duration, then counts what remains; if that count is already at the limit, the new request is denied. Because this evaluates a genuinely continuous, moving window rather than fixed blocks, it closes the boundary problem entirely, confirmed directly in the simulation above, where the identical straddling-burst scenario correctly capped at exactly ten allowed requests rather than twenty.
The cost is real, ongoing memory usage, since every request's timestamp needs to be stored, at least temporarily, for every distinct client being rate limited. For an API with a very large number of distinct clients or a long window duration, this can become a genuinely significant memory footprint, which is exactly the trade-off the next algorithm exists to reduce.
Sliding Window Counter: A Practical Approximation
The sliding window counter algorithm approximates a true sliding window's accuracy while keeping fixed window's cheap, constant memory footprint, by tracking just two counters, the current window's count and the previous window's count, and estimating the effective sliding count as a weighted combination of the two, based on how far into the current window the incoming request falls.
Run against the identical boundary-straddling burst from the fixed window example, this approach allowed eleven requests rather than the true sliding window's exact ten, a small, generally acceptable overshoot in exchange for using only two stored counters per client instead of a growing list of individual timestamps. This makes it the common practical choice for large-scale APIs specifically because it gets close to sliding window log's accuracy without its memory cost, and the small approximation error is rarely significant in practice for typical rate limiting purposes.
// Sliding window counter: weighted estimate using two counters
const weightPrev = 1 - (elapsedInCurrentWindow / windowMs);
const estimate = (previousWindowCount * weightPrev) + currentWindowCount;
const allowed = estimate < limit;
Token Bucket: Allowing Deliberate, Controlled Bursts
A token bucket holds a fixed maximum number of tokens, refilling at a steady, continuous rate over time, and each incoming request consumes one token if available, or is denied if the bucket is empty. What makes this meaningfully different from the sliding window approaches is that it explicitly allows a burst up to the bucket's full capacity at any moment the bucket happens to be full, rather than treating any burst as inherently something to prevent.
Run against the same boundary-straddling scenario, with a bucket capacity of ten and a refill rate matching the nominal ten-per-minute limit, the simulation correctly capped the burst at ten allowed requests, matching the sliding window log's accuracy while using only two pieces of state per client, the current token count and the last refill timestamp, rather than a growing list of individual timestamps.
The deliberate burst allowance is a genuine feature, not a compromise, for a lot of real-world traffic patterns. A client that's been idle for a while and then needs to send a small handful of requests in quick succession, a page load triggering several API calls at once, benefits from a token bucket's willingness to allow that burst up to capacity, rather than a strict sliding window that would spread those same few requests out artificially even when the client's overall average rate is well within bounds.
Leaky Bucket: Smoothing to a Constant Output Rate
Leaky bucket flips the token bucket's framing: instead of tokens being consumed by requests, incoming requests fill a queue (the bucket), which drains, or "leaks," at a fixed, constant rate regardless of how fast requests arrive. If the queue is full when a new request arrives, that request is rejected outright, rather than being queued indefinitely.
This produces a smoother, more consistent output rate than token bucket's burst-friendly behavior, at the cost of not accommodating legitimate bursts the same way, exactly the behavior confirmed in the simulation, where leaky bucket capped the burst similarly to sliding window counter rather than allowing the full token-bucket-style burst through. This makes leaky bucket a better fit specifically when the actual concern is protecting a downstream system that genuinely can't handle bursty traffic well, a legacy service or a rate-limited third-party API you're relaying requests to, rather than protecting your own API from abuse in a more general sense.
Side-by-Side: Verified Simulation Results
| Algorithm | Same burst test result (limit: 10/min) | Memory per client | Allows deliberate bursts |
|---|---|---|---|
| Fixed window | 20 allowed (boundary exploit) | 1 counter | Unintentionally, at boundaries |
| Sliding window log | 10 allowed (exact) | Grows with request volume | No |
| Sliding window counter | 11 allowed (close approximation) | 2 counters | Minimal, by approximation error only |
| Token bucket | 10 allowed (exact) | 2 values (tokens, timestamp) | Yes, deliberately, up to bucket capacity |
| Leaky bucket | 11 allowed (smoothed) | 2 values (queue level, timestamp) | No, enforces steady output rate |
Which Algorithm Fits Which Situation
Token bucket is the most commonly recommended general-purpose choice for API rate limiting today, specifically because it combines accurate limit enforcement with a deliberate, controllable allowance for legitimate burst traffic, matching how real client applications actually behave, occasional bursts of a few related requests rather than a perfectly smooth, constant request rate.
Sliding window counter is a strong choice when memory efficiency at very large client scale matters more than the small approximation error it introduces, which describes most large, high-traffic public APIs where sliding window log's per-request timestamp storage would become a genuinely significant memory cost across millions of distinct rate-limited clients.
Leaky bucket fits best specifically when protecting a downstream system with a genuinely fixed processing capacity, a webhook delivery queue, a third-party API with its own strict rate limit you need to respect when relaying requests to it, where a smooth, constant output rate matters more than accommodating burst traffic from your own clients.
Fixed window remains defensible specifically for coarse, low-stakes limits where the boundary exploit's worst case, roughly double the nominal limit in a short window, isn't a meaningful risk, an internal admin tool with a generous limit and trusted users, for instance, where implementation simplicity outweighs the accuracy gap.
Distributed Rate Limiting: The Part That Gets Harder at Scale
All of the algorithms above assume a single, authoritative counter per client, which is straightforward on a single server but becomes a genuine coordination problem the moment an API runs behind a load balancer distributing requests across multiple servers. A client's requests might land on different servers across a short span of time, and if each server tracks its rate limit state independently, in local memory, the effective limit becomes the configured limit multiplied by the number of servers, since each server enforces its own separate, unaware-of-the-others count.
Redis is the standard solution, acting as a shared, centralized store for rate limit state that every server checks and updates, ensuring a client's true total request count is evaluated consistently regardless of which specific server handled any individual request. Implementing token bucket or sliding window counter logic against Redis, using atomic operations or Lua scripts to avoid race conditions between concurrent requests hitting the same client's counter simultaneously, is the common, production-proven approach for distributed rate limiting at real scale, and it's worth building or adopting a well-tested library for this rather than implementing the atomic-update logic from scratch, since subtle race conditions here are easy to introduce and hard to notice until they cause a real, if minor, limit-enforcement gap under concurrent load.
Choosing Your Actual Limit Values
A limit set from pure guesswork, rather than grounded in actual, observed legitimate traffic patterns, either blocks real, valid usage or provides essentially no meaningful protection at all. Reviewing genuine request logs from typical, well-behaved clients before setting a limit gives a realistic baseline to set the threshold comfortably above, rather than picking a round number with no connection to how the API is actually used in practice.
Different endpoints often warrant meaningfully different limits based on their actual cost to serve. A cheap, well-cached read endpoint can typically tolerate a considerably higher request rate than an expensive write endpoint triggering real downstream processing, and applying one blanket limit across every endpoint regardless of its actual resource cost either over-restricts the cheap ones or under-protects the expensive ones, exactly the same principle covered for REST API rate limiting and for weighting individual GraphQL fields by their actual resolution cost.
Real Scenarios
A public REST API serving third-party developers
Token bucket, communicated clearly through standard rate limit headers so well-behaved clients can proactively avoid hitting the limit rather than discovering it only through a rejected request.
A webhook receiver processing incoming events from a third-party service
Leaky bucket or a token bucket with a modest capacity, since the goal here is often protecting your own downstream processing capacity from a burst of incoming webhook deliveries, rather than limiting an external client's request rate in the traditional sense.
A GraphQL API with highly variable per-request cost
A complexity-weighted budget, using token bucket or sliding window mechanics against a computed query cost rather than a flat request count, is the appropriate adaptation, directly connecting to the cost-analysis approach covered in this site's GraphQL complexity guide.
A small internal tool with a handful of trusted users
Fixed window is a reasonable, low-effort choice here, since the boundary exploit's practical risk is minimal against a small, trusted, low-volume user base, and the implementation simplicity is a genuine advantage at this scale.
Common Mistakes
Implementing fixed window rate limiting for a genuinely security-sensitive endpoint, a login route, a password reset request, without recognizing the boundary burst exploit, leaves exactly the kind of gap an attacker attempting brute-force login attempts could exploit deliberately, doubling their effective attempt rate by timing requests around window boundaries.
Implementing per-server, in-memory rate limiting behind a load balancer without a shared, centralized store is a mistake that works perfectly in local development and single-server testing, then silently multiplies the effective limit in production the moment traffic is actually distributed across more than one server.
Setting a single, uniform limit across every endpoint regardless of actual per-endpoint cost either frustrates legitimate users of cheap, high-frequency endpoints or fails to adequately protect genuinely expensive ones, and it's worth reviewing endpoint-specific costs deliberately rather than defaulting to one number applied everywhere.
And rejecting rate-limited requests without informative headers, leaving a client to guess why it's being throttled and when it can safely retry, produces a worse integration experience than necessary; standard headers communicating the limit, remaining quota, and reset time let well-built clients self-regulate proactively rather than discovering the limit only through repeated failed attempts.
FAQ
Is fixed window rate limiting ever a reasonable choice?
For low-stakes, coarse limits where the boundary exploit's worst case isn't a meaningful risk, yes, and its implementation simplicity is a genuine advantage there. For anything security-sensitive or genuinely capacity-protective, a sliding window or token bucket approach is the safer default.
Why does token bucket allow bursts when sliding window doesn't?
Token bucket is deliberately designed to accommodate burst traffic up to its configured capacity, reflecting how real clients often behave, occasional clusters of related requests, rather than treating every burst as something to prevent outright the way a strict sliding window does.
Do I need Redis to implement rate limiting correctly?
Only once your API runs across more than one server. A single-server deployment can track rate limit state safely in local memory, but any distributed setup needs a shared store like Redis to avoid each server enforcing its own separate, unaware limit.
How much overshoot does sliding window counter's approximation actually cause?
In the simulated boundary-burst scenario here, it allowed eleven requests against a true sliding window's exact ten, a small, generally acceptable margin in exchange for using only two stored counters per client rather than a growing list of timestamps.

