Choosing Window Size for API Rate Limits

“1,000 requests per hour” and “17 requests per minute” describe almost the same throughput and behave nothing alike: one lets a client spend everything in the first second of every hour, the other paces them continuously. Window size is therefore a product decision as much as a technical one, and it interacts directly with the boundary behaviour described in fixed window vs sliding window.

The problem in concrete numbers

Take the same budget expressed three ways, and look at the worst-case burst a compliant client can produce:

Expression Equivalent rate Worst-case burst Worst-case instantaneous load
86,400 per day 1/s 86,400 in one second Your whole day’s traffic, at once
3,600 per hour 1/s 7,200 across a boundary Two hours’ budget in two seconds
60 per minute 1/s 120 across a boundary Two minutes’ budget, manageable
1 per second 1/s 2 across a boundary Bounded

Every row grants the same sustained throughput. The first row is a denial-of-service vector that you documented and invited. This is the fixed-window boundary problem, and the size of the window is what turns it from a curiosity into an outage.

Worst-case burst grows with the window at the same sustained rate Expressed per day the same budget permits a burst of eighty-six thousand requests, per hour seven thousand two hundred across a boundary, per minute one hundred and twenty, and per second only two. Same 1 request per second, four expressions per day: burst up to 86,400 requests in one second per hour: burst up to 7,200 across a boundary per minute: burst 120 per second: 2 the sustained rate is identical in every row; only the exposure changes

Decision matrix

Window Boundary burst Memory per key Feels like Best for
1 s 1 counter, 1 s TTL Strict pacing Write endpoints, expensive operations
10 s 2× of 10 s budget 1 counter Smooth General-purpose public APIs
1 min 2× of a minute 1 counter Forgiving of page loads Read endpoints, dashboards
1 h 2× of an hour 1 counter Budget-like Quotas, not rate control
1 day 2× of a day 1 counter Allowance Billing boundaries only
Sliding (log) None O(requests) Exact Billing-critical measurement
Sliding (weighted) ~1.1× 2 counters Smooth, cheap Where boundary bursts must be small

The pattern that solves most APIs is a pair: a short window for shape and a long window for budget. “10 requests per second and 5,000 per hour” bounds the instantaneous load at 20 and the sustained draw at 5,000, and both numbers are simple enough to publish.

Step-by-step: choosing the pair

python
# Two windows, one decision. Short window shapes; long window budgets.
import math, time

WINDOWS = [
    ("burst", 10, 100),        # name, seconds, limit  -> 10 rps sustained, 20 worst-case
    ("hourly", 3600, 5_000),   # fair-use ceiling
]

LUA = """
-- KEYS: one key per window. ARGV pairs: window_seconds, limit
local now = redis.call('TIME')[1]
local tightest_reset, blocked = 0, 0
for i, key in ipairs(KEYS) do
  local window = tonumber(ARGV[i * 2 - 1])
  local limit  = tonumber(ARGV[i * 2])
  local bucket = math.floor(now / window)
  local k = key .. ':' .. bucket
  local count = redis.call('INCR', k)
  if count == 1 then redis.call('EXPIRE', k, window * 2) end
  if count > limit then
    blocked = 1
    local reset = (bucket + 1) * window - now
    if tightest_reset == 0 or reset < tightest_reset then tightest_reset = reset end
  end
end
return { blocked, tightest_reset }
"""

def check(redis, identity):
    keys = [f"rl:{identity}:{name}" for name, _, _ in WINDOWS]
    args = [v for _, w, l in WINDOWS for v in (w, l)]
    blocked, reset = redis.eval(LUA, len(keys), *keys, *args)
    return {"allowed": blocked == 0, "retry_after": max(1, int(reset)) if blocked else 0}

Note the EXPIRE of window * 2: the key must outlive its own window so a request arriving late in the window still sees the count, and it must not outlive the next window or a stale counter blocks a fresh one.

How a short and a long window divide the work The short window bounds instantaneous load and shapes traffic, the long window bounds total usage against the plan, and a request must satisfy both before it is admitted. Two windows, two different jobs short window: 100 per 10 s protects backend capacity worst case 200 in a moment rejection means: slow down retry in seconds long window: 5,000 per hour protects the plan boundary 1.4 per second sustained rejection means: budget spent retry in minutes, or upgrade a request must satisfy both; report the tighter one in the headers Aligned windows against per-key offset windows With wall-clock alignment every client's window resets at the same instant and traffic spikes at each boundary; offsetting each key's window by a hash spreads the resets uniformly with identical semantics. When does each client's window reset? aligned to the clock every key resets at :00 a spike at every boundary capacity sized for the spike offset by a key hash resets spread across the window same limit, smooth arrivals capacity sized for the mean one hash in the bucket index removes a traffic spike you would otherwise buy hardware for

Gotchas and edge cases

  • Publishing only the long window. Clients then assume they may spend it instantly, and the ones that do are behaving exactly as documented.
  • Windows that do not divide evenly. A 45-second window means boundaries drift relative to the minute, which makes reasoning about dashboards and support conversations harder for no benefit. Prefer 1, 5, 10, 60, or 3,600 seconds.
  • Both windows binding at once. If the long window’s implied rate is above the short limit, the long limit never triggers and you have one limit with extra machinery. Check the arithmetic.
  • Aligned boundaries across all clients. Every key resetting at :00 produces a synchronised stampede. Offset the bucket by a per-key hash: floor((now + hash(key) % window) / window).
  • Reporting the wrong window in headers. A client told X-RateLimit-Reset: 3400 while the short window rejects it every second will retry uselessly. Report the tighter window and name it.
  • TTL equal to the window. A counter that expires exactly at the boundary can vanish while requests are still arriving in that window. Expire at twice the window.
  • Changing window size without versioning keys. Existing counters were bucketed by the old window; a change reinterprets them. Include the window in the key name.

Verification and testing

bash
# 1. Confirm the short window binds first and reports itself.
for i in $(seq 1 120); do
  curl -s -o /dev/null -D- -H "X-API-Key: $KEY" https://api.example.com/v1/ping |
    grep -i "^x-ratelimit-window\|^x-ratelimit-reset\|^HTTP/"
done | tail -6
# expect: HTTP 429 with x-ratelimit-window: burst and a reset of a few seconds

# 2. Confirm the long window still binds after a day of paced traffic.
#    Paced at the short limit, the hourly ceiling must be reached in ~50 minutes.
python3 - <<'PY'
import time, requests
sent = ok = 0
start = time.time()
while time.time() - start < 3600:
    r = requests.get("https://api.example.com/v1/ping", headers={"X-API-Key": KEY})
    sent += 1; ok += r.status_code == 200
    time.sleep(0.7)                      # under the short limit, over the hourly pace
print(f"sent={sent} accepted={ok}")      # accepted should plateau at ~5000
PY

# 3. Confirm boundary offsets are per key, not global.
#    Two keys should reset at different instants.
for k in key_a key_b; do
  curl -s -D- -o /dev/null -H "X-API-Key: $k" https://api.example.com/v1/ping |
    grep -i "^x-ratelimit-reset"
done
# the two reset values should differ; identical values mean every client resets together

Test three catches the stampede problem that only appears under load: if every client’s window resets at the same instant, your traffic graph develops a spike at the top of every minute that no amount of capacity planning explains.

Frequently Asked Questions

What window size should a general-purpose API use?

A pair: a short window of one to ten seconds to bound instantaneous load, and an hourly or daily window to express the plan's budget. A single window either allows a damaging burst if it is long, or makes the budget hard to describe if it is short.

Is a sliding window always better than a fixed one?

It removes the boundary doubling, at the cost of more state or more arithmetic. A weighted sliding window — two counters and an interpolation — gets most of the benefit for one extra counter, which is why it is the common production compromise. An exact sliding log is worth it only when the count drives billing.

Should windows be aligned to the wall clock?

Aligned windows are easier to explain but make every client reset simultaneously, producing a stampede at each boundary. Offsetting each key's window by a hash of the key keeps the semantics identical while spreading resets uniformly across the window.

Which window should the response headers describe?

The one that is currently tightest, and name it explicitly with an extra header or a field in the error body. A client that is told about the hourly budget while the per-second limit is rejecting it will retry far too soon and stay rejected.