System design · 11 of 20

Designing a distributed rate limiter

The five algorithms and their exact behaviour, where to enforce, how to make one global limit work across many nodes, the counter store, multi-tenant fairness, and what to tell the client.

Updated 2026-09-18
On this page

A rate limiter is a counter and a comparison, which is why people underestimate it. The difficulty is that the counter has to be shared by a hundred servers in three regions, updated a million times a second, correct enough to bill against, and read in less than a millisecond — and it must never be the reason the system goes down. The reliability article introduced the algorithms as one tool among many; this one builds the service.

The map

Read this first when short on time. Every branch is a section below.

Figure 1. The whole article on one page. Every branch is a section below; fold what you know, open what you do not.

What a limiter must do, and at what size

A rate limiter does three jobs that are easy to confuse. It protects capacity, keeping total load inside what the system can serve. It enforces a plan, making a paid quota mean something. And it isolates tenants, so one customer's runaway script does not consume everybody else's service. The first is about the system, the second about money, and the third about fairness — and they want different algorithms, different accuracy and different failure behaviour, which is why a real deployment runs several limiters rather than one.

Checks        1,000,000 per second at peak
Rules         3-5 evaluated per request (global, tenant, user, route)
Keys          ~10 million distinct subjects active in a window
State         ~100 B per key (counter, window start, TTL) -> ~1 GB
Latency       budget +1 ms at p99 for the whole check
              in-datacenter round trip ~0.5 ms -> one store call, not five
Correctness   a few percent over the limit is fine
              under the limit is not, when a customer paid for it

The latency line is the constraint that shapes the design. A limiter sits in front of every request, so its own cost is added to every request; a budget of one millisecond means one network round trip, which means the several rules for a request must be evaluated in a single call rather than one call each.

The correctness line is the one worth arguing about early. Exactness is expensive and, for a capacity limit, pointless — the number 1,000 requests per second was itself a guess, so a limiter that sometimes admits 1,015 is not wrong in any meaningful sense. For a billed quota the asymmetry flips: letting a customer slightly over is a rounding error, cutting them off early is a support ticket. Stating the tolerance and its direction up front decides most of the later choices.

The algorithms, and exactly how each one behaves

Five algorithms, in order of increasing quality and decreasing simplicity. The differences only become visible at the boundaries, which is exactly where load arrives.

Fixed window. Keep one counter per key per window and reset it when the window rolls. It is one increment and one comparison, and it has a well-known flaw: a client that sends its whole allowance at the end of one window and again at the start of the next pushes twice the limit through in a moment straddling the boundary.

Limit: 100 requests per minute · fixed window window 10:00:00 – 10:00:59 100 requests, all at 10:00:59 window 10:01:00 – 10:01:59 100 requests, all at 10:01:00 200 requests in one second both windows were individually within the limit
Figure 2. The fixed-window boundary burst. Every counter was correct and the limit was still exceeded twofold, which is why this algorithm is only acceptable as a coarse outer guard.

Sliding window log. Store the timestamp of every request, drop those older than the window, and count what is left. It is exactly right, and it costs memory proportional to the traffic it is permitting — a client allowed 5,000 requests an hour needs 5,000 timestamps. Correct, and rarely affordable.

Sliding window counter. The practical compromise: keep the current window's counter and the previous one's, and estimate the rate over the trailing window by weighting the previous count by how much of it still overlaps.

estimate = previous_count x overlap_fraction + current_count

At 10:01:15 with a 60 s window:
  45 s of the previous window still overlaps -> overlap_fraction = 0.75
  previous_count = 80, current_count = 30
  estimate = 80 x 0.75 + 30 = 90    -> under a limit of 100, allow

It assumes the previous window's requests were spread evenly, which is not true, so it can be slightly wrong in both directions. Published measurements at very large scale put the error in the thousandths of a percent of requests — two counters and one multiplication for effectively exact behaviour.

Token bucket. A bucket holds up to capacity tokens and refills at rate tokens per second; a request takes one token or is rejected. This is the only algorithm on the list that models burst and sustained rate as separate numbers, which is usually what a product actually wants: 100 requests per second sustained, but a burst of 500 is fine. Implementation is two numbers per key — token count and last refill time — with refill computed lazily at read time rather than by a background job.

GCRA. The generic cell rate algorithm is a leaky bucket expressed as a single timestamp. Instead of counting tokens it tracks the theoretical arrival time of the next permitted request: each request costs an emission interval of time, and a burst tolerance says how far ahead of that schedule a client may run. One value per key, no counters, and the smoothest output of the five.

AlgorithmState per keyAccuracyReach for it when
Fixed window1 counterUp to 2× at boundariesA coarse outer guard where exactness does not matter
Sliding log1 timestamp per requestExactSmall limits where the audit trail is worth the memory
Sliding counter2 countersError in thousandths of a percentThe default for API limits at scale
Token bucketTokens + timestampExact for rate, burst by designBurst and sustained rate must be separate promises
GCRA1 timestampExact, perfectly smoothMemory matters and you want no burstiness at all
In the wild
  • Cloudflare published its move to the sliding window counter and the measured error, which is why that approximation is now the default recommendation.
  • Stripe uses token buckets in Redis, with separate limiters for request rate and for concurrent work in flight.
  • GitHub's API exposes a fixed hourly window of 5,000 requests and reports remaining and reset values on every response.

Where to enforce

A limit can be applied at three places, and the useful design applies it at all three with different strictness, because each layer knows something the others do not.

flowchart TD
  C["client"] --> E["edge: per IP, coarse, cheap"]
  E --> G["gateway: per tenant and per route, the plan limits"]
  G --> S["service: per dependency, concurrency and cost"]
  S --> D[["database or downstream"]]
  E -. "drops floods before they cost anything" .-> X["rejected"]
  G -. "enforces what the customer paid for" .-> X
  S -. "protects the thing that actually breaks" .-> X
Figure 3. Three layers, three jobs. The edge is cheapest and least informed, the service is most informed and most expensive to reach.

The edge sees the raw connection and nothing else — no tenant, no route semantics — but it is the only place that can reject a flood before it costs any money, since everything past it consumes capacity. Its limits are coarse and per IP or per token.

The gateway knows who the caller is and what they are asking for, so it is the natural home for plan limits: a thousand requests a minute on this endpoint for this plan. This is where the interesting limiter lives, and the rest of the article is mostly about it.

The service knows what a request actually costs. A search that scans a million rows and a key lookup are one request each to the gateway and a thousandfold difference here, which is why service-level protection is usually a concurrency limit rather than a rate limit: at most N requests in flight against this dependency, regardless of arrival rate. Concurrency limits self-adjust to the work's real cost in a way that rate limits cannot.

Tip

Rate limits and concurrency limits answer different questions and you usually want both. A rate limit bounds how often work starts; a concurrency limit bounds how much is in progress, which is what actually exhausts connection pools and memory.

Making one limit work across many nodes

Here is the actual problem. The limit is global — a thousand requests a minute for this tenant — and the traffic arrives at a hundred nodes, each seeing a fraction of it. Something has to reconcile them, and every option trades accuracy against latency.

ApproachHow it worksCost
Local onlyEach of N nodes enforces limit ÷ N with no coordinationFree and fast, but wrong whenever traffic is uneven: a tenant hashing to two nodes gets 2/N of their quota
Central store (chosen)Every node checks and updates one shared counterOne round trip per request, ~0.5 ms, and the store becomes a dependency of every request
Local plus reconcileNodes decide locally and asynchronously publish their counts, adjusting their shareNo latency cost and no hard ceiling: overshoot during the reconciliation interval
Quota leaseA node leases a block of the allowance and spends it locallyOne round trip per block instead of per request, with unused leases wasted until they expire

The central store is the right default because it is the only one that is both simple and correct, and because half a millisecond is affordable at a gateway. The local-only option deserves its bad reputation: the even split is exactly wrong in the common case, since load balancing is per connection and a single client's traffic rarely spreads evenly.

Where local decisions become attractive is at very high rates for very large tenants, and there the fix for the even-split problem is quota redistribution: nodes periodically report usage to a coordinator, which reallocates the global allowance in proportion to demand, so idle nodes give up their share to busy ones. It converges within a few reporting intervals and removes the store from the request path entirely.

flowchart TD
  subgraph Central["Central counter"]
    direction LR
    N1["node A"] --> ST[["shared counter"]]
    N2["node B"] --> ST
  end
  subgraph Lease["Quota lease"]
    direction LR
    M1["node A holds 200"] --> CO["coordinator"]
    M2["node B holds 800"] --> CO
  end
  Central ~~~ Lease
Figure 4. Two shapes. The central counter pays one round trip per request for exactness; leasing hands each node a share of the allowance and has a coordinator reallocate it every few seconds, paying a window of overshoot for zero request-path cost.

The counter store

The store holds one small record per active key, is read and written on every request, and must apply the check and the update as one indivisible step.

That last point is the whole reason this needs care. The obvious implementation — read the counter, compare it, write it back — is a race: two nodes read 999 simultaneously, both decide to allow, and both write 1,000. Under load that race is not rare, it is the normal case. The fix is to push the entire decision into the store as a single atomic operation: a small server-side script that reads the counter, applies the algorithm, writes the new state, sets the expiry and returns both the verdict and the remaining allowance.

-- one atomic call: token bucket refill, spend, and report
-- KEYS[1] = bucket key   ARGV = rate, capacity, now_ms, cost
local state    = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens   = tonumber(state[1]) or tonumber(ARGV[2])
local ts       = tonumber(state[2]) or tonumber(ARGV[3])
local elapsed  = math.max(0, tonumber(ARGV[3]) - ts) / 1000
tokens = math.min(tonumber(ARGV[2]), tokens + elapsed * tonumber(ARGV[1]))

local allowed = tokens >= tonumber(ARGV[4])
if allowed then tokens = tokens - tonumber(ARGV[4]) end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[3])
redis.call('PEXPIRE', KEYS[1], 60000)
return { allowed and 1 or 0, math.floor(tokens) }

Three details in that script matter beyond the algorithm. The refill is computed from elapsed time on read, so there is no background job and an idle key costs nothing. The expiry is reset on every write, so keys for subjects that stop sending traffic disappear on their own — which is what keeps ten million keys from becoming a hundred million. And now_ms is passed in or taken from the store's own clock rather than each caller's, because a node whose clock is a few seconds off would otherwise shift its window boundaries and quietly get a different limit.

Key naming follows from the rules: rl:{tenant}:{rule}:{window}, with the tenant in a hash tag so that all of one tenant's rules land on the same shard and can be evaluated in one pipelined call. Point operations only — a limiter must never scan.

Watch out

A single enormous tenant turns their counter into a hot key, and no amount of sharding helps because every request for that tenant hits the same record. Split it deliberately: shard the counter into k sub-counters each holding limit ÷ k, and have each node pick one consistently. Accuracy drops a little, and the key stops being a bottleneck.

How wrong is it allowed to be?

Every performance decision here buys speed with accuracy, so it is worth stating what accuracy is actually needed — in a design, in one sentence, before the choices are made.

Exactness across regions is not merely expensive but impossible in the sense that matters: a global counter that is correct at all times requires consensus on every request, and no limiter will pay tens of milliseconds for that. What is achievable is exactness within a region and approximation across them, which is why global limits are usually enforced per region with the allowance divided, and only reconciled afterwards for billing.

Within a region, the tolerances that work in practice: a capacity limit may overshoot by a few percent, because the number was an estimate anyway. A billed quota must never undershoot — cutting a paying customer off early is worse than letting them have a little extra. And an abuse limit should be strict but is allowed to be slow, since a scraper throttled two seconds late has not achieved much.

Multi-tenant fairness

One limit per tenant is not enough, because tenants are not the only thing that needs protecting. A real system evaluates a hierarchy, cheapest first, and the first rejection ends the evaluation.

flowchart TD
  R["request arrives"] --> G{Global capacity left?}
  G -- no --> S["429, shed by priority"]
  G -- yes --> T{Tenant quota left?}
  T -- no --> S
  T -- yes --> U{User limit left?}
  U -- no --> S
  U -- yes --> E{Route limit left?}
  E -- no --> S
  E -- yes --> A["admit, decrement all four"]
Figure 5. The rule hierarchy. Evaluate the cheapest and most protective limit first, and decrement counters only once the request is admitted, or a rejected request still consumes quota it never used.

The noisy neighbour problem is what the tenant level exists for: without it, one customer's retry storm consumes the shared pool and everyone else sees errors caused by someone they have never heard of. Per-tenant caps on a shared resource are the fix, and they work best as a share rather than a fixed number — a tenant may use up to their weighted share when the system is busy, and more than it when it is idle.

When the global limit is the one being hit, rejection should not be uniform. Shed by priority: reject the batch export before the interactive request, the free tier before the paid one, and never the health check or the payment callback. This makes the limiter a load-shedding mechanism as well as a fairness one, and it needs request priority to be known at the gateway, which means the API has to carry it.

Telling the client

A limiter that rejects silently, or with a generic error, produces clients that retry immediately and make everything worse. The response is part of the design.

HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 12
Content-Type: application/json

{ "error": "rate_limited", "scope": "tenant", "retry_after_seconds": 12 }

Three things make this useful. Retry-After tells the client exactly when to come back, which converts a retry storm into a scheduled request. The limit and remaining headers let a well-behaved client slow down before being rejected, which is the difference between a client that gets throttled occasionally and one that lives permanently at the limit. And naming the scope tells the caller which limit they hit, so they know whether to slow down, upgrade, or spread their work across users.

On the client side, the rules are the ones from the reliability article: honour Retry-After when it is present, back off exponentially with jitter when it is not, keep a retry budget so that retries can never exceed a small fraction of traffic, and never retry a 429 immediately.

Failure, and the clock

The limiter is in front of everything, so its own failure modes are more important than its accuracy.

Fail open or fail closed? The answer is per rule rather than global. A plan-enforcement limit should fail open: if the counter store is unreachable, serving customers is better than rejecting them, and the revenue at risk exceeds the overage. An abuse limit should fail closed, because its whole purpose is to stop traffic that is already hostile. A capacity limit should fail to a conservative local fallback — each node enforcing a fixed share — because failing open there means the outage the limiter existed to prevent.

Clock skew shifts window boundaries between nodes. If each node uses its own clock, two nodes disagree about which window a request belongs to, and a client can exploit the seam. Taking the time from the counter store, in the same atomic call that does the check, removes the problem entirely.

Store outage is the case to rehearse. The limiter should degrade to a local, coarse, conservative limit rather than disappearing — and it should say so loudly in metrics, because a system running on fallback limits is one incident away from either an outage or a very large bill.

What breaks first

  1. Round trips per request. Not the store's capacity but the number of calls: five rules evaluated as five sequential calls is 2.5 ms of added latency. Pipeline them into one, co-locating a tenant's keys with a hash tag so that is possible.
  2. The hot key. The largest tenant's counter is touched on every one of their requests, and it lives on exactly one node. Sub-counters are the standard fix, at a small cost in accuracy.
  3. Key cardinality. Per-IP limits on a large public surface can produce far more keys than expected. Short TTLs keep the set bounded, and the memory estimate should be made from distinct subjects per window rather than from total traffic.
  4. The blast radius of the limiter itself. The most expensive failure in this article is not being slightly wrong about a limit, it is the limiter becoming a single point of failure in front of everything. Local fallbacks and per-rule failure modes are what bound it.

Recap

  • A limiter does three different jobs — protect capacity, enforce a paid plan, isolate tenants — which want different algorithms, accuracies and failure behaviour, so real deployments run several.
  • The latency budget of about one millisecond means one store round trip per request, so all of a request's rules must be evaluated in a single call.
  • Fixed windows allow twice the rate across a boundary; sliding logs are exact but cost memory proportional to traffic; the sliding window counter estimates with two counters and is accurate to thousandths of a percent.
  • Token buckets model burst and sustained rate as separate numbers, which is usually what the product wants. GCRA does the same with a single timestamp and no burstiness.
  • Enforce at three layers: coarse per-IP at the edge, plan limits at the gateway, and concurrency limits at the service, which adapt to the real cost of work in a way rate limits cannot.
  • Splitting a global limit evenly across nodes is wrong in the common case because traffic is not evenly distributed; a central counter is the simple correct default.
  • Quota leasing with periodic redistribution removes the store from the request path at the cost of overshoot during a reconciliation interval.
  • Check and update must be one atomic operation in the store, or concurrent nodes both admit the request that crossed the limit.
  • Compute refills lazily on read, reset the TTL on every write so idle keys disappear, and take the current time from the store to avoid clock skew.
  • A very large tenant is a hot key; shard their counter into sub-counters and accept slightly lower accuracy.
  • State the accuracy tolerance and its direction: capacity limits may overshoot, billed quotas must never undershoot, and cross-region exactness is not worth buying.
  • Evaluate rules as a hierarchy, cheapest first, decrementing only on admission; shed by priority when the global limit is the one being hit.
  • Answer with 429, Retry-After and limit headers so clients can slow down before being rejected rather than retrying into the wall.
  • Choose fail-open or fail-closed per rule: open for plan limits, closed for abuse limits, conservative local fallback for capacity.
  • The worst failure is not inaccuracy, it is the limiter becoming a single point of failure in front of the whole system.

Questions

Try answering each one out loud before opening it. Lead with the one-line answer, then the reasoning, then one detail from a real system.

What are the different jobs a rate limiter does, and why does it matter?

Protecting capacity, enforcing a paid plan and isolating tenants from each other — three jobs that want different accuracy and opposite failure behaviour, which is why one limiter configured one way cannot do all three.

  • A capacity limit may fail conservatively and overshoot slightly; a plan limit should fail open and must never undershoot.
  • An abuse limit should fail closed, since the traffic it targets is already hostile.
  • Tenant isolation is about shares of a pool rather than absolute numbers.

Saying which job a given limit is doing settles most of its configuration immediately.

Why is the fixed window algorithm considered unsafe?

Because a client can send a full allowance at the end of one window and another full allowance at the start of the next, pushing twice the limit through in a moment while every individual counter stays within bounds.

  • The flaw is at the boundary, which is exactly where synchronised clients arrive.
  • It remains acceptable as a coarse outer guard where twice the limit is still survivable.
  • Its appeal is real: one counter, one increment, one comparison.

The sliding window counter fixes it for the cost of one extra counter and a multiplication.

Explain the sliding window counter and its error.

It keeps the current and previous window counts and estimates the trailing-window rate as the previous count weighted by how much of it still overlaps, plus the current count.

  • With a 60-second window, 15 seconds into the current one, 75 percent of the previous window overlaps.
  • It assumes the previous window's traffic was evenly spread, so it can err slightly in both directions.
  • Measurements at very large scale put the error in the thousandths of a percent of requests.

Two integers per key for effectively exact behaviour is why this is the default recommendation for API limits.

When would you choose a token bucket over a sliding window?

When burst and sustained rate are separate product promises — a hundred requests a second sustained, but a burst of five hundred is fine — because the bucket's capacity and refill rate express exactly those two numbers.

  • State is two values per key, with the refill computed lazily at read time rather than by a background job.
  • A sliding window has only one number and therefore cannot distinguish burst from rate.
  • GCRA is the same idea compressed into a single timestamp, with no burstiness at all.

Most public APIs describe their limits in bucket terms without saying so, which is a good sign it matches how people think about quotas.

Why enforce limits at more than one layer?

Because each layer knows something the others do not: the edge can reject a flood before it costs anything but knows nothing about tenants, the gateway knows the plan, and only the service knows what a request actually costs.

  • Two requests that look identical to a gateway can differ a thousandfold in work at the service.
  • Service-level protection is usually a concurrency limit rather than a rate limit, since concurrency tracks real cost.
  • An edge-only design admits expensive requests; a service-only design pays for every flood.

The layering is coarse outside and precise inside, which is the same pattern as defence in depth generally.

Why not simply give each of N nodes one Nth of the limit?

Because traffic is not distributed evenly across nodes: a single client's connections land on a few nodes, so they are throttled at a small fraction of the quota they paid for while most nodes' shares sit unused.

  • It is worst exactly when it matters most — one big tenant, few connections, a hard limit.
  • It also cannot adapt to nodes being added or removed without redistributing every share.
  • The repair is periodic redistribution in proportion to observed demand, which converges in a few intervals.

Local enforcement is still attractive at very high rates, because it removes the store from the request path entirely.

Why must the check and the update be a single atomic operation?

Because read-compare-write is a race: two nodes read a counter at 999, both decide the request is under the limit, and both write 1,000, so the limit is exceeded by exactly the number of concurrent checkers.

  • Under load this is the normal case rather than a rare interleaving.
  • The fix is a server-side script that reads, applies the algorithm, writes, sets the TTL and returns the verdict in one step.
  • The same call should return the remaining allowance so the response headers can be populated without a second read.

Conditional writes achieve the same thing in stores without scripting, at the cost of a retry loop on contention.

How do you keep a limiter's memory bounded when the key space is huge?

Set a TTL equal to the window on every key and reset it on every write, so any subject that stops sending traffic disappears without a cleanup job.

  • Memory should be estimated from distinct subjects active in a window, not from total request volume.
  • Per-IP limits on a large public surface generate far more keys than per-tenant limits, which is worth checking before deploying them.
  • A limiter must never scan the key space — every operation is a point operation.

Lazy refill pairs with this: an idle key costs nothing until it is touched, and then it expires again.

Why take the current time from the counter store rather than from each node?

Because clock skew between nodes shifts window boundaries, so two nodes disagree about which window a request belongs to and a client can aim requests at the seam.

  • Reading the time inside the same atomic call removes the disagreement entirely.
  • Skew also breaks token-bucket refill maths, since elapsed time can come out negative.
  • Clamping elapsed time to zero is a cheap defence against a clock stepping backwards.

It is the same class of problem as clock-based ordering in distributed systems, in a much smaller and more tractable form.

Your largest customer's counter is a hot key. What do you do?

Shard that one counter into k sub-counters each carrying limit ÷ k, and have each node map to one consistently, which spreads the writes across k nodes at the cost of some accuracy.

  • Sharding by key does not help on its own, since every request for the tenant hashes to the same record.
  • The accuracy loss comes from uneven arrival across sub-counters, which is bounded and measurable.
  • Batching or leasing for that tenant specifically is the alternative, trading exactness for no store traffic at all.

It is the same single-hot-key problem as in the caching article, and it has the same shape of answer.

How accurate does a rate limiter need to be?

Less accurate than people assume, and asymmetrically: a capacity limit may overshoot by a few percent because the limit itself was an estimate, while a billed quota must never cut a paying customer off early.

  • Exactness requires serialising every decision, which no limiter can afford in its latency budget.
  • Across regions, exactness is effectively unobtainable, so global limits are usually enforced per region and reconciled afterwards for billing.
  • Stating the tolerance and its direction in the design settles most implementation choices.

Being explicit about this is what separates a limiter that is fast from one that is slow for no benefit anyone asked for.

How do you stop one tenant degrading everyone else?

Per-tenant caps on every shared resource, expressed as a weighted share rather than an absolute number, so a tenant may use more when the system is idle and is held to their share when it is busy.

  • Without it, one customer's retry storm consumes the pool and unrelated customers see the errors.
  • Concurrency caps matter as much as rate caps, since in-flight work is what exhausts pools and memory.
  • Rules are evaluated cheapest first and counters decremented only on admission, or rejected requests consume quota they never used.

This is why multi-tenant platforms publish per-account limits even for resources that are technically shared.

What should a 429 response contain?

A Retry-After value, the limit, the remaining allowance and the reset time, plus which scope was hit — so a client knows when to come back and whether to slow down, upgrade or spread its work.

  • Remaining and reset let a well-behaved client throttle itself before being rejected at all.
  • Naming the scope distinguishes "you are going too fast" from "your plan is too small".
  • A silent drop guarantees immediate retries, which is the worst possible client behaviour.

The IETF RateLimit header fields standardise this so clients can implement it once rather than per vendor.

The counter store becomes unavailable. Should the limiter allow or reject?

It depends on the rule, and the decision should be made per rule in advance: plan limits fail open because rejecting paying customers costs more than the overage, abuse limits fail closed, and capacity limits fall back to a conservative local share.

  • Failing open on a capacity limit produces exactly the outage the limiter existed to prevent.
  • Failing closed on a plan limit turns a cache incident into a full outage.
  • Running on fallback limits must be loudly visible in metrics, because it is a temporary state with real risk attached.

The general principle is that the limiter must never be the largest single point of failure in front of the system.

How would you rate limit by cost rather than by request count?

Charge a variable number of tokens per request according to its expected cost, and settle the difference afterwards when the real cost is known — a token bucket handles this naturally because taking N tokens is the same operation as taking one.

  • The estimate can come from the endpoint, the page size requested, or a model of past executions.
  • Post-hoc settlement means an unexpectedly expensive request is paid for out of the next window.
  • A concurrency limit at the service is the cheaper approximation, since it bounds work in progress without predicting cost.

Cloud APIs that publish request-unit or capacity-unit pricing are doing exactly this, with the cost table made public.

What breaks first in a limiter at scale?

Round trips, not capacity: evaluating five rules as five sequential store calls adds more latency than the whole budget allows, so the rules must be pipelined into one call with the tenant's keys co-located.

  • After that, the largest tenant's hot key, then key cardinality from per-IP rules.
  • Memory should be sized from distinct active subjects, not from request volume.
  • The final risk is not performance at all but the limiter's own availability.

Every one of those is a design decision rather than a tuning parameter, which is why they belong in the design document.