Rate limiting an API comes down to three decisions: which algorithm counts requests, where that counter lives so the read-modify-write stays atomic, and how the result is communicated back to callers. The first two determine whether your limit is accurate. The third determines whether clients can back off deliberately or only by hitting a wall. For that third part, the IETF HTTPAPI working group has specified RateLimit and RateLimit-Policy — with the important caveat that the specification is still an Internet-Draft rather than an RFC.

Why advertise quota at all

Without quota headers, a client discovers it exceeded a limit only by receiving a 429. Every act of backing off costs a failed request, and the client cannot distinguish "almost out" from "just went over."

Two bad behaviors follow. Cautious clients throttle themselves far below the quota you actually granted, wasting capacity you were willing to give. Aggressive clients hammer into 429s continuously, turning your limiter into an expensive busy-wait that consumes your connections and CPU rather than theirs.

Publishing remaining quota and reset timing lets clients slow down before being refused. That is not a courtesy to the caller — it is load you avoid carrying.

Writing RateLimit and RateLimit-Policy

draft-ietf-httpapi-ratelimit-headers-11 comes from the HTTPAPI working group. The current revision is dated 23 May 2026 and expires 24 November 2026. It is an Active Internet-Draft and has not been published as an RFC, which belongs in your architecture decision record: field semantics can still change before standardization.

Both fields use Structured Fields, expressed as Lists of Items with parameters.

The two fields do different jobs

RateLimit-Policy describes the server's quota policy — what you allow and over what window. The draft states the field value should remain consistent across a sequence of responses, because it describes the rule rather than the moment.

RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400

That declares two concurrent policies: burst at 100 per 60 seconds, daily at 1000 per 86400 seconds. Multiple simultaneous policies are the normal case — you usually want to damp instantaneous spikes and cap daily volume, and one window cannot do both.

RateLimit describes the current service limit, meaning what is left right now:

RateLimit: "default";r=50;t=30

The parameters

For RateLimit-Policy:

Parameter Required Meaning
q Yes Quota allocated, non-negative integer
qu No Quota units, default requests; permitted values requests, content-bytes, concurrent-requests
w No Time window in seconds, non-negative and non-zero
pk No Partition key, byte sequence

qu deserves attention because most implementations ignore it. Counting requests is the default, but the draft explicitly allows content-bytes and concurrent-requests. For upload endpoints or large responses, bytes track actual cost far better than a request count does. For streaming or long-lived connections, concurrency is the only dimension that means anything.

For RateLimit:

Parameter Required Meaning
r Yes Available quota, non-negative integer
t No Effective window in seconds over which that quota applies
pk No Partition key, byte sequence

One sentence from the draft needs to reach whoever writes your client SDK: clients must not assume that a positive available quota guarantees further requests will be served. r=50 is not a prepaid voucher. Other callers sharing the partition may consume it, and the server may apply unrelated protections under load. Clients still have to handle 429 correctly.

Interaction with Retry-After

The draft specifies behavior for both sides when the fields appear together, and the two directions differ:

  • Servers: when returning both Retry-After and RateLimit, the Retry-After value should not reference a point in time earlier than the end of the effective window.
  • Clients: when receiving both, Retry-After must take precedence.

The common implementation error inverts the server rule — setting Retry-After shorter than the window to get clients back sooner, which only manufactures a round of guaranteed failures.

Choosing an algorithm

Algorithm Burst handling Memory Main weakness
Fixed window Poor Smallest Boundary admits 2x quota
Sliding window log Exact Large, one timestamp per request Memory and CPU suffer at high QPS
Sliding window counter Good Small Approximate near boundaries
Token bucket Controlled burst Small, two numbers Two parameters to reason about

The fixed-window boundary problem is worth spelling out because it is the most common mistake. With a limit of 100 per minute, a client sending 100 requests at 12:00:59 and another 100 at 12:01:00 is within limits both times — yet 200 requests landed in a two-second span, double what you intended. For a fragile downstream, that instantaneous spike is often exactly what tips it over.

Token bucket is a sensible default for most APIs: capacity controls how large a burst you tolerate, refill rate controls the sustained average, and those two knobs map cleanly onto two genuinely separate requirements.

Making it atomic in Redis

A limiter is inherently read-modify-write, and across multiple application instances that sequence must be indivisible.

Fixed window: the INCR-then-EXPIRE trap

The obvious implementation pairs INCR with EXPIRE:

INCR ratelimit:user-42:1754400000
EXPIRE ratelimit:user-42:1754400000 60

Two commands, and that is the bug. If the process dies between them, the key never expires. The counter only grows, and that user is locked out permanently. This is close to impossible to reproduce in tests — it surfaces only after an unrelated production crash, and presents as "one specific customer is mysteriously rate limited," which is a miserable thing to debug from the symptom.

Make the two steps indivisible instead. Redis script execution is atomic; the scripting documentation states that a script is not interrupted by other commands while it runs:

-- KEYS[1] = counter key, ARGV[1] = limit, ARGV[2] = window seconds
local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
  return {0, 0, redis.call('TTL', KEYS[1])}
end
return {1, tonumber(ARGV[1]) - current, redis.call('TTL', KEYS[1])}

Three return values: allowed, remaining, seconds to reset — which map directly onto r and t.

Token bucket: why a script is mandatory

A token bucket must read the last refill timestamp and token count, add tokens for elapsed time, decide whether enough remain, then write back. Interrupt that anywhere and two concurrent requests can both evaluate "sufficient tokens" against the same stale state, admitting more than the bucket allows.

-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill per second, ARGV[3] = now (seconds), ARGV[4] = cost
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local tokens = tonumber(bucket[1]) or capacity
local ts = tonumber(bucket[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate)

local allowed = tokens >= cost
if allowed then tokens = tokens - cost end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 60)
return {allowed and 1 or 0, math.floor(tokens)}

Two details matter. Time arrives through ARGV rather than being read inside the script, so replicas and AOF replay produce identical results — reading a clock inside a script makes the same script diverge across nodes. And the key expires after "time to refill the bucket plus margin," so idle buckets are reclaimed instead of growing the keyspace without bound.

If you are planning a Redis upgrade alongside this work, limiter keys are prime candidates to become hot keys, and the HOTKEYS command added in 8.6 is built for exactly that diagnosis — we covered it and the rest of that release in the Redis 8.6 upgrade guide.

Returning 429 properly

429 Too Many Requests is defined in RFC 6585 section 4, and Retry-After semantics live in RFC 9110.

A complete refusal:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
RateLimit-Policy: "burst";q=100;w=60
RateLimit: "burst";r=0;t=23
Retry-After: 23

{"type":"https://example.com/problems/rate-limited","title":"Too Many Requests","status":429,"detail":"Exceeded 100 requests per minute."}

Retry-After: 23 agrees with t=23, satisfying the draft's "not earlier than the end of the effective window" rule. The body names which policy fired so integrators are not left guessing between your burst and daily limits.

Worth remembering: a 429 is not free. It still consumes a connection, a response, and egress. For sources that persistently exceed limits, dropping at the edge costs meaningfully less than generating a well-formed refusal in the application.

What distributed deployment changes

Per-instance limiting is not global limiting. Counting in process memory across four instances means you actually admit four times your configured value. Either move state to Redis, or accept the multiplier and divide the quota by instance count — the latter breaks the moment autoscaling changes that count.

The limiter must not become a single point of failure. When Redis is unreachable, you either admit everything (fail-open) or refuse everything (fail-close). That is a business decision, not a technical one: for a paid API, fail-open means giving away the product; for an internal service, fail-close means causing an outage yourself. Whichever you choose, write it explicitly and alert on it, rather than letting it be decided by wherever an exception happens to get swallowed.

Long-lived connections need a different dimension. Counting requests does almost nothing for SSE or WebSocket endpoints, where one connection holds resources indefinitely. Those want concurrent-requests, or separate caps on connection duration and message rate. Connection lifecycle, heartbeats, and reconnection for SSE are their own problem — we worked through them in FastAPI SSE in production, and your limiting dimension should line up with that lifecycle.

Partition keys need thought. Limiting by IP punishes an entire office behind one NAT. Limiting by user ID does nothing against unauthenticated abuse. Limiting by API key is precise but only applies to authenticated traffic. Most production systems layer these, and the draft's pk parameter exists precisely so the server can tell the client which partition the returned quota belongs to.

Failure modes

The limiter becomes the bottleneck. A synchronous Redis round trip on every request looks cheap at one millisecond until you remember it applies to every request. High-frequency endpoints can pre-allocate a small batch of quota locally and reconcile periodically, trading precision for latency.

Clock skew misaligns windows. Instances computing window boundaries from local time will disagree when machines drift by a few hundred milliseconds. Either derive time from Redis or accept approximation with windows long enough that the error does not matter.

Retry amplification. Clients that retry immediately on 429 without jitter all come back at the same instant. Return Retry-After and document a requirement for exponential backoff with jitter.

Confusing rate limiting with idempotency. Limiting controls frequency; idempotency keys prevent duplicate effects. They solve different problems. Webhook endpoints in particular need signature verification and a replay window, which we covered separately in webhook signatures and replay protection.

Changing quota without telling anyone. This is what RateLimit-Policy is for. Updating the header alongside the configuration is more reliable than emailing integrators.

Pre-launch checklist

  • Counter state lives in shared storage, or per-instance quota is divided by a fixed instance count
  • The read-modify-write sequence is atomic via a script; no window exists where INCR succeeds and EXPIRE is lost
  • Scripts do not read the system clock; time is passed in, so replicas and replay agree
  • Limiter keys have expirations, so the keyspace cannot grow without bound
  • Fail-open versus fail-close on limiter outage is an explicit choice with alerting
  • 429 responses carry Retry-After, not earlier than the end of the window reported in t
  • RateLimit-Policy matches actual configuration and is updated when quota changes
  • Client documentation states that a positive r does not guarantee service, and 429 must still be handled
  • Long-lived endpoints are limited by concurrency or duration rather than request count
  • Partition key choice has been evaluated against NAT-shared addresses and unauthenticated traffic

FAQ

Is the RateLimit header a standard? Not yet. It is an Active Internet-Draft from the IETF HTTPAPI working group, currently at revision 11, not an RFC. Usable today, but manage it as something that can still change rather than freezing it into a compatibility promise.

Do I need to send both fields? They serve different purposes — policy describes the rule, RateLimit describes what remains. Sending only the latter still lets clients back off, but integrators cannot learn the shape of the quota in advance.

Can I keep the older X-RateLimit-* headers? Many APIs still use them and clients recognize them. Emitting both during a transition is pragmatic, but note there was never a common specification — X-RateLimit-Reset is a duration in some APIs and an absolute timestamp in others.

Does r=0 guarantee rejection? It reports that the partition's quota is exhausted. The converse does not hold: the draft is explicit that a positive r does not guarantee service.

Token bucket or leaky bucket? Token bucket permits a burst when the bucket is full; leaky bucket enforces a constant outflow. Choose by asking whether the thing you are protecting can absorb bursts.

Gateway or application? Anything you can reject at the edge should not reach the application, since the 429 itself costs resources. But per-user and per-key quotas usually need identity that only the application has. The common split is coarse IP protection at the edge and fine-grained quota in the service.