The four common API rate limiting algorithms are fixed window, sliding window (log or counter), token bucket and leaky bucket. Token bucket suits most public APIs because it allows short bursts while holding an average rate. Sliding window counter gives smoother limits than fixed window for almost the same storage. Whichever you pick, reject excess requests with 429 Too Many Requests from RFC 6585, include a Retry-After header, and tell clients their quota up front with the RateLimit and RateLimit-Policy fields from the IETF draft. Then document all of it in your OpenAPI spec so client authors can plan for it.
How each algorithm counts requests
Every rate limiter answers one question per request: has this client used up its allowance? The algorithms differ in how they define "allowance" over time, what state they store per client, and how they behave at the edges.
Fixed window
Keep one counter per client per clock window, such as 100 requests per minute, and reset it when the minute rolls over. It needs one integer and one expiry per client, and it is easy to explain to customers. Its weakness is the boundary. A client can send 100 requests at 12:00:59 and another 100 at 12:01:00, so the server sees 200 requests in two seconds while the client never broke the stated limit. If your backend cannot absorb twice the limit in a burst, fixed window is the wrong choice.
Sliding window log and sliding window counter
A sliding window log stores a timestamp for every accepted request and counts the ones inside the last 60 seconds. It is exact, and it costs memory in proportion to the limit: a 10,000-per-hour plan means up to 10,000 timestamps per client. The sliding window counter approximates the same result with two counters. It weights the previous window's count by how much of that window still overlaps the last 60 seconds, then adds the current count.
With a limit of 100 per minute, 80 requests in the previous minute, 30 so far in the current one, and 15 seconds into the current minute, 45 of the previous 60 seconds still count. The estimate is 80 x 0.75 + 30 = 90, so the client has 10 requests left. The estimate assumes the previous window's requests were spread evenly, which is the trade you make for constant storage.
Token bucket
A bucket holds up to a fixed number of tokens and refills at a steady rate. Each request removes one token, or more for expensive operations; when the bucket is empty, the request is rejected. Capacity sets the largest burst and the refill rate sets the long-run average, so you can allow a client that has been idle to send 50 requests at once while still capping it at 10 per second over time. State per client is a token count and a timestamp.
export class TokenBucket {
constructor(capacity, refillPerSecond) {
this.capacity = capacity;
this.refillPerSecond = refillPerSecond;
this.tokens = capacity;
this.updatedAt = Date.now();
}
take(cost = 1) {
const now = Date.now();
const elapsed = (now - this.updatedAt) / 1000;
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSecond);
this.updatedAt = now;
if (this.tokens >= cost) {
this.tokens -= cost;
return { allowed: true, remaining: Math.floor(this.tokens) };
}
const retryAfter = Math.ceil((cost - this.tokens) / this.refillPerSecond);
return { allowed: false, remaining: 0, retryAfter };
}
}
// 50-request burst, 10 requests per second on average
const bucket = new TokenBucket(50, 10);
console.log(bucket.take()); // { allowed: true, remaining: 49 }Charging by cost matters for batched requests. OWASP's API4:2023 entry on unrestricted resource consumption describes an attacker who sends many upload operations in one batched GraphQL request to get around a per-request limit. Charging tokens by work done, rather than by HTTP request, closes that gap.
Leaky bucket
Requests enter a queue that drains at a fixed rate; when the queue is full, new requests are dropped. The output is perfectly smooth, which protects a downstream service that cannot take bursts at all, such as a legacy system or a third-party API with its own strict limit. The cost is latency: a request at the back of the queue waits, and clients see slow responses before they see rejections.
| Algorithm | State per client | Bursts | Main failure mode |
|---|---|---|---|
| Fixed window | 1 counter | Up to 2x the limit across a window boundary | Boundary spikes |
| Sliding window log | 1 timestamp per request | None beyond the limit | Memory grows with the limit |
| Sliding window counter | 2 counters | Close to none | Approximation when traffic is uneven |
| Token bucket | Token count and timestamp | Up to bucket capacity | Large capacity lets an idle client spike |
| Leaky bucket | A queue | Absorbed by queueing | Added latency, then drops |
Picking an algorithm
| Scenario | Recommendation | Alternative |
|---|---|---|
| Clients send bursts (page loads, batch jobs) and your backend can absorb them | Token bucket, with capacity set to the largest burst you accept | Sliding window counter if you prefer a hard per-window number |
| You publish a simple "N per minute" plan and need predictable enforcement | Sliding window counter | Fixed window only if 2x boundary bursts are harmless |
| A downstream dependency cannot handle any burst | Leaky bucket in front of that dependency | Token bucket with a small capacity |
| Each request needs exact accounting, such as paid metered calls | Sliding window log, if the memory per client is acceptable | — |
What to count against: key and cost
RFC 6585 leaves this open: it does not define how the server identifies the client or how it counts requests. Pick the key that matches who pays for the traffic. An API key or OAuth client ID fits partner APIs. A user ID fits first-party apps where one customer runs many devices. An IP address is a fallback for unauthenticated routes, and a poor one, because many users can share one address behind a corporate NAT. Most APIs need more than one limit: a per-key quota for fairness and a stricter per-route limit on expensive or abusable operations. OWASP's API4:2023 scenario of an attacker triggering a paid SMS on a forgot-password endpoint again and again is a case for a per-route limit keyed on the target phone number, not only on the caller.
The 429 response
RFC 6585 defines 429 Too Many Requests for exactly this case. The response should include details explaining the condition and may include Retry-After; caches must not store it. RFC 9110 defines Retry-After as either an HTTP-date or a number of seconds. Seconds are easier for clients and avoid clock skew. Use 503 Service Unavailable instead when the whole service is overloaded, not one client; the HTTP status codes reference lists both. For the body, a problem details object keeps rate-limit errors in the same shape as the rest of your errors, as the error handling guide recommends.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 41
RateLimit-Policy: "burst";q=100;w=60
RateLimit: "burst";r=0;t=41
{
"type": "https://api.example.com/problems/rate-limited",
"title": "Too many requests",
"status": 429,
"detail": "The burst policy allows 100 requests per 60 seconds. Try again in 41 seconds."
}The IETF RateLimit and RateLimit-Policy headers
Retry-After only arrives after the client is already blocked. The IETF HTTPAPI working group's draft, draft-ietf-httpapi-ratelimit-headers (version 11, May 2026), adds two fields that let clients slow down before that. RateLimit-Policy describes a quota: q is the quota in units, w the window in seconds, qu the unit type (requests by default, or content-bytes or concurrent-requests), and pk an optional partition key. RateLimit reports the current state of a named policy: r is the remaining quota and t the seconds until it resets. In the example above, the burst policy allows 100 requests per 60 seconds and the client has 0 left for 41 more seconds.
- Send both fields on successful responses too, not only on 429, so clients can pace themselves.
- The draft says that when a response has both RateLimit and Retry-After, Retry-After takes precedence, and Retry-After should not point to a time earlier than the end of the effective window.
- It is still an Internet-Draft, not an RFC. Earlier versions used separate RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset fields, and many APIs still send vendor X-RateLimit-* headers. Document exactly which fields you send and keep the policy names stable.
Document limits in your OpenAPI spec
Define the headers once under components.headers, put the 429 response under components.responses, and reference both from each operation. Generated clients and docs then show the limit next to the operation instead of in a separate wiki page.
openapi: 3.0.3
info:
title: Reports API
version: 1.0.0
paths:
/v1/reports:
get:
summary: List reports
responses:
"200":
description: A page of reports
headers:
RateLimit-Policy:
$ref: "#/components/headers/RateLimit-Policy"
RateLimit:
$ref: "#/components/headers/RateLimit"
"429":
$ref: "#/components/responses/TooManyRequests"
/v1/report-exports:
post:
summary: Start a report export job
responses:
"202":
description: Export accepted
headers:
RateLimit-Policy:
$ref: "#/components/headers/RateLimit-Policy"
RateLimit:
$ref: "#/components/headers/RateLimit"
"429":
$ref: "#/components/responses/TooManyRequests"
components:
headers:
RateLimit-Policy:
description: Quota policies for this client, for example "burst";q=100;w=60
schema:
type: string
RateLimit:
description: Remaining quota and seconds until it resets, for example "burst";r=12;t=41
schema:
type: string
Retry-After:
description: Seconds to wait before retrying
schema:
type: integer
minimum: 0
responses:
TooManyRequests:
description: Rate limit exceeded
headers:
Retry-After:
$ref: "#/components/headers/Retry-After"
RateLimit:
$ref: "#/components/headers/RateLimit"
content:
application/problem+json:
schema:
type: object
required: [type, title, status]
properties:
type:
type: string
title:
type: string
status:
type: integer
detail:
type: stringThis spec scores 100 on APIForge's design score, and so does a copy with every 429 response and rate-limit header removed and a plain 400 response in their place. The score checks that error responses exist, not which ones, so a missing 429 is a review item for a human. Put "every operation declares 429 with Retry-After" on your review list, next to the checks in the API quality checklist.
What clients should do with these signals
A well-behaved client
Read RateLimit on every response
When r gets low, spread the remaining requests over the t seconds left instead of spending them at once.
On 429, wait for Retry-After
Retry-After wins over any other signal. If it is missing, back off exponentially with random jitter so a fleet of clients does not retry in lockstep.
Retry only what is safe to repeat
GET, PUT and DELETE are idempotent by definition. Retry a POST only if the API supports an idempotency key, or you risk creating the same order twice.
Cap the retries
Give up after a fixed number of attempts and surface the error. An infinite retry loop against a 429 is how a client turns a rate limit into an outage.
Check your limits from the browser
Load your spec in the API testing workbench, send a request to a limited operation, and open the response Headers tab to confirm RateLimit, RateLimit-Policy and Retry-After arrive with the values you documented. The workbench sends single requests, so it is for checking headers and the 429 body, not for load testing. One caveat: when CORS blocks the browser request and APIForge falls back to its server proxy, your API sees the proxy's IP address, so an IP-keyed limit counts those requests against the proxy rather than your machine. Test with an API key if your limits are keyed on credentials.