Hermes Wiki

Fixed Window Counter: Implementation and Boundary-Burst Mitigation

Concept

Fixed window counter is the simplest rate-limiting algorithm to describe: divide time into fixed, non-overlapping buckets (e.g. one bucket per calendar minute), keep a single counter per client key per bucket, increment on each request, and reject once the counter exceeds the limit — the bucket resets to zero the instant the next window starts. Its appeal is real: O(1) memory per client, one counter and one reset timestamp, trivial to implement and reason about. See Rate Limiting Algorithms for how it compares conceptually against sliding window and token/leaky bucket approaches. This note goes one level deeper — into the two problems that actually show up building a fixed window counter for real: the boundary-burst math, and getting the counter's atomicity right in a distributed deployment.

The boundary-burst problem, quantified: a "100 requests/minute" limit enforced as a fixed window means a client can send 100 requests at 0:00:59 (the tail end of one window) and another 100 at 0:01:00 (the start of the next), for 200 requests inside a single real second — double the nominal limit, and it requires no exploit, only timing. The flaw isn't rare or theoretical; any client retrying near a window boundary triggers it by accident.

The atomicity problem: implementing the counter as two separate Redis calls — INCR key followed by EXPIRE key 60 — looks correct but has a real race: if the key already exists (this isn't the first request in the window), the EXPIRE call still runs and resets the TTL, silently extending the window on every single request instead of only on the window's first request. Under concurrent load across multiple instances, this can leave a window that never actually expires, or expires at an unpredictable time relative to when it started — the fixed window stops being fixed. The correct implementation is a single atomic Lua script executed server-side by Redis: increment the counter, and set the expiry only if the post-increment value is 1 (meaning this call just created the key). Because the whole script runs as one atomic operation with no other Redis command able to interleave, there's no window for a race between the increment and the conditional expiry.

Tradeoffs

Mitigation Fixes Cost
Do nothing (plain fixed window) N/A — cheapest possible limiter Accepts up to 2x burst at every window boundary
Atomic Lua script (INCR + conditional EXPIRE) Counter/TTL race condition under concurrency Slightly more implementation complexity; still doesn't fix the boundary-burst behavior, only the counter's correctness
Combine a coarse + a fine-grained window (e.g. 100/minute AND 5/second) Bounds worst-case burst size without abandoning fixed window's simplicity Two counters and two checks per request instead of one
Switch to sliding window counter algorithm Eliminates boundary bursting close to entirely More implementation complexity; see Rate Limiting Algorithms for the sliding-window approximation this trades up to

The practical middle ground most teams land on isn't "replace fixed window with something more complex" — it's layering a second, tighter fixed window on top of the first (a per-second cap alongside the per-minute cap), which bounds the worst-case boundary burst to the tighter window's limit without giving up fixed window's O(1)-per-key simplicity.

When to use / when not to

  • Use plain fixed window counter as a reasonable first rate limiter for endpoints where an occasional 2x burst at a boundary genuinely doesn't matter — internal tooling, low-stakes endpoints, anything not abuse-sensitive.
  • Always implement the counter increment and TTL-set as a single atomic operation (a Lua script in Redis, or the equivalent atomic primitive in another store) — the naive two-call INCR/EXPIRE pattern is a real, commonly-hit bug under concurrent traffic, not a hypothetical edge case.
  • Add a second, fine-grained window (per-second on top of per-minute) the moment the endpoint is abuse-sensitive but a full sliding-window or token-bucket rewrite isn't justified yet — this is the cheapest real mitigation for the boundary-burst flaw.
  • Move to sliding window counter or token bucket (see Rate Limiting Algorithms) once the boundary-burst behavior is a demonstrated, exploited problem rather than a theoretical one — don't pre-optimize past fixed window's simplicity without evidence it's needed.
  • In a horizontally-scaled service, never implement the counter as in-process/per-instance memory — it must live in a shared store (Redis is the default) or the effective limit silently multiplies by the instance count.

Common pitfall

Pipelining INCR and EXPIRE as two separate Redis commands (even back-to-back) instead of a single atomic script, on the assumption that "they run right after each other so it's basically atomic." Under real concurrent load, this resets the TTL on every request rather than only the window's first, which either extends the effective window indefinitely (if requests never stop arriving) or produces an inconsistent, drifting window boundary — a subtler and harder-to-notice bug than the boundary-burst behavior itself, because it doesn't show up in casual testing, only under sustained concurrent traffic.

Engineering Lens

Fixed window counter is a good case study in how a "trivial" algorithm still has a real correctness bar in production: the conceptual description (count requests, reset on a timer) is genuinely simple, but the distributed-systems reality (atomic increment-plus-expiry across concurrent requests hitting a shared store) is where an implementation actually succeeds or quietly breaks. The transferable lesson for a design review is to ask not just "which rate-limiting algorithm did you pick" but "how is the counter's state mutation made atomic under concurrency" — the same INCR-then-EXPIRE race shows up any time a counter and its expiry/reset are set as two operations instead of one, well beyond rate limiting specifically (session TTLs, idempotency-key expiry, any "count and expire" pattern backed by a shared store).

Sources

Hermes Wiki