Rate Limiting Across Multiple Servers

Requirements and the Design Framework · 40 min

System Design · Module 1 · Lesson 4 of 4 · 40 min

Rate Limiting Across Multiple Servers

Design one client budget across many app instances, compare fixed windows with token buckets, and specify a limiter-outage policy.

Trace the global-limit bug →
The logical budget must have one coordinated owner even when requests reach different application instances.

01 The idea

The problem this lesson solves

One API client may send three accepted requests per minute. You enforce this with a counter inside the application and the test passes. Then you deploy a second app instance. Five requests alternate between the two instances and all five are accepted. The bug is not in incrementing an integer; it is in where the decision lives. This lesson develops a rate limiter from that failure, then compares the algorithms and contracts needed to make the behavior precise.

You need basic programming and arithmetic. This module introduces the design vocabulary; the next module develops HTTP step by step.

Fixed windowA counter reset or replaced at a defined time boundary.
Token bucketA budget that refills over time and allows a defined maximum burst.
Fail-openA policy that permits work when the limiter cannot provide a decision.

02 Worked example

Share a three-request budget across two servers

Two app instances share one client budgetApp ASame clientApp BSame clientAtomic budgetOne decisionProtected APIAllowed callsTwo app instances share one atomic budgetApp ASame clientApp BSame clientAtomic budgetOne decisionProtected APIAllowed calls
Both app instances consult the same atomic budget. Three accepted requests consume the window; further requests receive 429.
1
Set the budgetOne client may make 3 accepted requests in this window. Five requests arrive; all belong to that same window.
2
Try local countersWith two app servers and round-robin routing, server A accepts requests 1, 3 and 5. Server B accepts requests 2 and 4. All five pass, exceeding the intended budget.
3
Share the decisionA shared atomic counter sees 1, 2, 3, 4 and 5. Only the first three are allowed. The last two receive 429; they do not call the protected API.
4
Name the remaining limitThis fixes cross-server counting, but a fixed window still permits a boundary burst. A requirement about any rolling 60 seconds needs a different algorithm.

03 The method

Understand the mechanism

Define what the limit actually counts

A rate limit is a policy about identity, resource and time. “Three requests per minute” leaves several questions unanswered. Is the identity a user, API client, IP address or organization? Are reads and writes charged equally? Does an attempted request consume budget, or only a request that reaches the protected operation? Is the minute a wall-clock window or any rolling interval?

For the first example, count accepted requests from one authenticated client in a fixed 60-second window. The budget is three. Attempts four and five are denied before they call the protected API. If admitted work later fails, this policy still counts the admission; refunding tokens would be a separate design with its own races.

Identity must be stable across app instances. A client-supplied arbitrary account id is not sufficient unless authenticated and authorized. IP-based limits can protect anonymous endpoints, but many legitimate users may share an address and one attacker may use many addresses. A service may combine per-client and global limits for different goals.

The limiter is also different from a concurrency limit. Ten requests per second can still create hundreds of simultaneous operations if each takes a long time. A concurrency limit caps work currently in progress; a rate limit controls admissions over time. Protecting a dependency may require both.

Fixed windows and the boundary burst

A fixed-window limiter associates a counter with a client and window number, such as floor(time/60). During that window, an atomic decision admits requests while the count remains within the policy. Old window records can expire after they are no longer needed.

With a budget of three, requests at 10, 20 and 30 seconds pass; a request at 40 seconds is rejected. But consider three requests at 59.9 seconds and three at 60.1 seconds. They belong to different fixed windows, so all six can pass within roughly 0.2 seconds. The implementation has obeyed a fixed-window contract and violated a stricter “at most three in any sixty seconds” contract.

This is why algorithm choice follows the wording of the requirement. A fixed window is inexpensive and easy to explain. It can be appropriate when bursts around boundaries are acceptable. It should not be advertised as exact rolling-window enforcement.

An implementation that runs INCR and then sets expiration in a separate request also needs to consider a crash between them. The counter might be created without its intended expiry. Combine the relevant state transition and expiry initialization in a server-side atomic operation, and define how window identity is computed across callers.

Sliding logs, weighted windows and token buckets

An exact sliding-log limiter retains admission timestamps. At time t it removes timestamps outside the chosen interval, counts the survivors and admits only if budget remains. It can enforce a rolling-window policy precisely under its stated boundary convention. Its state and work grow with the number of retained events, so it may be expensive for high-volume identities.

A sliding-window counter approximates the recent load using a current bucket and a weighted part of a previous bucket. It uses less state but loses exact information about when requests arrived within the earlier bucket. The approximation error matters near bursty boundaries; call it an approximation rather than presenting it as equivalent to a log.

A token bucket has capacity B and refill rate r tokens per second. Before an admission, compute tokens = min(B, old_tokens + r×elapsed). If at least one token remains, subtract one and admit. Otherwise reject or wait according to policy. With B=3 and r=1/s, three immediate requests can pass, a fourth immediate request cannot, and another request can pass after one second if no others consume the refill.

The bucket permits bursts up to its capacity while limiting longer-term admission rate. Over an interval of length t, a full starting bucket can permit approximately B + r×t admissions, subject to discrete decisions and the implementation. It is not the same contract as “no more than B requests in every interval of length t.”

Make the shared decision atomic

Return to two app instances, A and B. Each private counter starts at zero. Round-robin arrivals 1,3,5 reach A and 2,4 reach B. A accepts three and B accepts two, so five requests consume a supposedly shared budget of three. Sticky routing may hide this until routing changes or an app restarts.

Both instances need to consult a shared authority or a designed distributed quota-allocation scheme. For the simple shared-counter version, the decisive operation must combine checking the budget and changing the state. A separate GET followed by SET is unsafe: A and B can both read count=2 and both conclude that one slot remains.

An atomic increment-and-compare can count every attempt, returning whether the resulting value is within the budget. An atomic check-and-increment can instead count admissions only. Both can enforce a fixed-window admission cap, but they have different counter meanings. Define what the stored value represents before using it in metrics or retry guidance.

A token bucket similarly needs one atomic read-refill-deduct-write transition for an identity. Making only the final write atomic is insufficient if the refill decision used stale state. The authoritative store’s failure and replication behavior then become part of the limiter’s guarantee.

Decide what happens when the limiter cannot answer

A timeout contacting the limiter is not proof that the client exceeded its quota. It is a failure to obtain a decision. Failing open admits the request despite that uncertainty; failing closed refuses or defers it. Choose according to the operation’s cost and risk, and describe the user-visible behavior.

A public search endpoint may prefer a degraded local cap during a limiter outage to preserve partial service. An expensive irreversible operation may prefer rejection until it can obtain a trustworthy decision. A service error is often more honest than a quota-exceeded response when the quota itself is unknown. Do not teach clients to interpret infrastructure failure as normal budget exhaustion.

When a client is actually throttled, HTTP 429 communicates too many requests. Retry guidance should reflect the algorithm and policy. A fixed-window reset time may be known; a shared token bucket’s future availability can change because other requests consume tokens. Clients still need bounded retries with backoff and jitter.

The limiter can itself become a bottleneck. A shared store adds a network hop and concentrates state. Local quota allocation can reduce that cost, but allocated tokens may be stranded on quiet instances or overshoot a desired global policy unless the allocation protocol accounts for them. This is an explicit performance-versus-precision decision.

Verify behavior under races and failures

Test more than a sequential series of requests. Start many concurrent attempts when exactly one admission remains and verify that only one wins. Send requests through different app instances. Cross a window boundary. Restart an app. Delay the limiter response until the caller times out, then retry.

Separate admissions from completed business work in measurements. Three admissions do not guarantee three successful API operations. Likewise, a denied attempt should not have already triggered the expensive side effect. The limiter belongs before that side effect, while authentication and request validation may need to happen first to choose the correct identity and cost.

Observe allowed, denied and decision-error counts separately, plus decision latency and store saturation. A rise in denied requests may mean abuse or a policy that is too strict. A rise in decision errors suggests an availability problem. Combining them into one rejection metric hides the difference.

The design is defensible when you can state the exact policy, prove that concurrent requests share one valid decision boundary, and explain what happens when that boundary is unavailable. The word “distributed” alone supplies none of those guarantees.

Guided tutorial

  1. Reproduce the private-counter bug

    Make a table with request number, selected instance, local count and outcome. Route requests 1 through 5 as A, B, A, B, A with budget three on each instance. You will record five admissions. This demonstrates why testing only one process misses the distributed error.

  2. Repeat with one atomic budget

    Replace the two counters with one admission count. For each request, check and consume one slot as an indivisible action. Requests 1–3 pass and 4–5 fail. Next start two requests together at count two: only one may observe and consume the remaining slot.

  3. Compare the time contracts

    Put three admissions at 59.9 seconds and three at 60.1 seconds. Fixed windows accept all six. An exact rolling sixty-second cap of three rejects the later group while the earlier admissions remain in the window. Then trace a capacity-three token bucket with one token per second to see why controlled bursts are a separate policy.

  4. Inject decision failure

    Assume the shared store does not answer within the deadline. Write the response and fallback for your chosen endpoint before touching code. Record decision errors separately from genuine quota denials. Run the two console presets and explain why their admission totals differ while the offered five requests remain the same.

Policy pseudocode

policy: 3 requests per client per fixed 60-second window
identity: authenticated API client
state: counter(client, window), expires after window
decision: atomic_increment(counter) <= 3
on denied: return 429 with retry guidance
on counter timeout: apply the documented failure policy

Check your result

The local-counter case accepts all five requests. The shared atomic-budget case accepts three and rejects two, regardless of which app receives them.

Compare local and shared admission decisions

This sequential Python model makes ownership visible. A real distributed limiter must perform the shared check-and-change atomically; this dictionary is not a distributed implementation.

budget = 3
routes = ["A", "B", "A", "B", "A"]
local = {"A": 0, "B": 0}
local_allowed = shared_allowed = shared_count = 0
for server in routes:
    if local[server] < budget:
        local[server] += 1
        local_allowed += 1
    if shared_count < budget:
        shared_count += 1
        shared_allowed += 1
print(f"local_allowed={local_allowed}")
print(f"shared_allowed={shared_allowed}")
print(f"shared_denied={len(routes)-shared_allowed}")

Expected output

local_allowed=5
shared_allowed=3
shared_denied=2

Each private counter is locally correct and the global policy is still violated. The shared model has one decision boundary. In a concurrent implementation, a lock, transaction or server-side atomic primitive must preserve that boundary.

05 Cheat sheet

Review the decisions

Use this table to explain the mechanism and its limit without memorizing a product name.

Choice or conceptRememberCheck
Local counter per instanceNo network hopGlobal budget multiplies with instances
Shared atomic counterOne logical decisionNetwork latency and a shared dependency
Fixed windowSmall state and easy expiryBurst near a window boundary
Token bucketExplicit burst allowanceNeeds coordinated token and timestamp updates

06 Where & why

Check the primary references

Use official references for protocol and implementation details. The questions here are teaching prompts.

Primary reference
HTTP 429 rate-limit response

Read HTTP 429 rate-limit response

Primary reference
Redis atomic server-side scripting

Read Redis atomic server-side scripting

State the implementation version and the workload assumptions when defending your own design.

07 Interview questions

Explain the reasoning out loud

Answer before opening the explanation. The questions progress from vocabulary to decisions and failure behavior.

What does Fixed window mean?
A counter reset or replaced at a defined time boundary.
What does Token bucket mean?
A budget that refills over time and allows a defined maximum burst.
What does Fail-open mean?
A policy that permits work when the limiter cannot provide a decision.
Why does a shared budget require atomic updates?
Two instances can read the same earlier value and each allow work. The check and state transition must be coordinated for the chosen policy.
Is every three-per-minute limiter equivalent?
No. Fixed windows, rolling windows and token buckets permit different bursts. Define the accepted traffic pattern before selecting an algorithm.
What identity belongs in the counter key?
A trusted authenticated caller or a deliberately chosen network identity, plus the relevant policy scope. An arbitrary untrusted client_id is easily rotated.
Does fail-open always improve availability?
It may preserve requests briefly but can overload the protected service. Evaluate both the immediate response and downstream recovery.
Why count denied traffic in capacity estimates?
The limiter must still inspect it. Protected-service QPS and limiter-decision QPS can differ substantially.

08 Practice problems

Practice a new case

Solve the new cases before revealing hints. Complete worked solutions follow the set.

Count a budget

Easy
One client sends six requests to three independent servers, each permitting two; find the possible accepted total.
Show the hint
Independent counters create independent allowances.

Choose identity

Easy
Explain why a caller-chosen client_id is unsuitable as the sole quota identity.
Show the hint
The caller can change it.

Availability budget

Medium
Compute the downtime allowed by 99.95 percent time-based availability over 30 days.
Follow-up
Keep the time unit consistent.
Show the hint
Multiply total minutes by the unavailable fraction.

Boundary burst

Medium
For a fixed window of 10 seconds allowing 4 requests, give a trace that accepts 8 requests inside 1 second.
Follow-up
All requests must obey their individual fixed-window budgets.
Show the hint
Place requests on opposite sides of a boundary.

Failure decision

Medium
Choose a limiter-outage policy for public article reads and a paid GPU generation endpoint, with one consequence each.
Follow-up
Availability and cost protection matter differently.
Show the hint
Name what an unbounded request could consume.

Design review

Hard
Write a one-page rate-limiter design for 20 app instances with one shared client budget, then explain two residual failure modes.
Follow-up
Adding instances must not multiply the budget.
Show the hint
Trace two concurrent calls and then a store timeout.

Worked solutions

1. Count a budget

All six can pass, while a shared global budget of two should admit only two.

2. Choose identity

The caller can rotate the string to obtain new budgets. Use authenticated identity or another explicitly defended identity policy.

3. Availability budget

30 × 24 × 60 = 43,200 minutes. The unavailable fraction is 0.0005, so the budget is 21.6 minutes.

4. Boundary burst

Send four at t=9.8 seconds and four at t=10.2 seconds. Each fixed window accepts four, yet eight are accepted in 0.4 seconds. A rolling-window requirement would reject this behavior.

5. Failure decision

Article reads could use a bounded local fallback with conservative limits; accepted traffic may exceed the global budget. GPU requests could fail closed with a retryable response to bound spend, at the cost of failed requests during the outage.

6. Design review

Use authenticated identity and an atomic shared decision keyed by client and window. Explain expiry and denied responses. Residual risks include shared-store unavailability, a hot client key, and boundary bursts; specify a failure policy, monitoring and a more suitable algorithm if rolling limits are required.