Client-Side Rate-Limit State
The cheapest 429 is the one you never send — and a client that reads the RateLimit-Remaining header on every response can throttle itself before it trips the limit instead of reacting after. This guide sits in the frontend resilience and UX handling area and covers the client-side state machine: parsing the remaining/limit/reset headers into a local quota model, gating outgoing requests against it, surfacing the number to the user, and keeping that state coherent across multiple browser tabs hitting the same API.
Reactive retry (backoff, Retry-After) handles the limit after you hit it. Proactive client state is the complementary half: it spaces requests so the limit is hit far less often.
Mechanism: the local quota model
Every rate-limited response carries enough to reconstruct the server’s view of your budget. The client keeps a small, per-key record and updates it on each response:
remaining— requests left in the current window (fromRateLimit-Remaining/X-RateLimit-Remaining).limit— the window’s ceiling (RateLimit-Limit).resetAt— epoch ms whenremainingrefills (RateLimit-Reset, normalized).cooldownUntil— epoch ms set when a429lands, cleared after.
The invariant: never send when remaining <= reserve and now < resetAt, where reserve is a small safety margin. Each update is O(1); the whole model is a handful of numbers per API key, cheap enough to live in memory and mirror to storage.
Configuration reference
| Param | Type | Default | Range | Effect |
|---|---|---|---|---|
reserve |
number | 2 |
0 – 10 | Stop sending while this many requests remain — a safety margin against races |
deferStrategy |
enum | until-reset |
block / until-reset / drop | What to do when the gate closes |
share |
enum | broadcast |
none / storage / broadcast | How tabs share the quota record |
storageKey |
string | rl:state |
— | localStorage/BroadcastChannel namespace |
staleMs |
number | 60000 |
1 000 – 600 000 | Treat a stored record older than this as unknown |
epochSniffThreshold |
number | 1e9 |
— | Above this, a -Reset value is an absolute epoch |
Implementation walkthrough
A small store updated on every response, queried before every send. Mirror it to localStorage so a new tab starts warm, and broadcast updates so open tabs stay coherent.
// rl-state.ts — proactive client-side quota tracking shared across tabs.
interface Quota { remaining: number; limit: number; resetAt: number; cooldownUntil: number; }
const KEY = "rl:state";
const RESERVE = 2;
let q: Quota = load() ?? { remaining: Infinity, limit: Infinity, resetAt: 0, cooldownUntil: 0 };
const bc = "BroadcastChannel" in self ? new BroadcastChannel(KEY) : null;
bc?.addEventListener("message", (e) => { q = e.data as Quota; }); // adopt peer updates
function load(): Quota | null {
try { return JSON.parse(localStorage.getItem(KEY) ?? "null"); } catch { return null; }
}
function persist() {
localStorage.setItem(KEY, JSON.stringify(q)); // mirror for new tabs + storage event
bc?.postMessage(q); // push to already-open tabs
}
// Call after every response — keeps the local model in sync with the server.
export function ingest(res: Response) {
const rem = res.headers.get("ratelimit-remaining") ?? res.headers.get("x-ratelimit-remaining");
const lim = res.headers.get("ratelimit-limit") ?? res.headers.get("x-ratelimit-limit");
const reset = res.headers.get("ratelimit-reset") ?? res.headers.get("x-ratelimit-reset");
if (rem != null) q.remaining = Number(rem);
if (lim != null) q.limit = Number(lim);
if (reset != null) {
const n = Number(reset);
q.resetAt = n > 1e9 ? n * 1000 : Date.now() + n * 1000; // epoch vs seconds-left
}
if (res.status === 429) q.cooldownUntil = q.resetAt || Date.now() + 1000;
persist();
}
// Call before every send — returns ms to wait, or 0 if clear to go now.
export function gateMs(): number {
const now = Date.now();
if (now < q.cooldownUntil) return q.cooldownUntil - now; // hard 429 cooldown
if (q.remaining <= RESERVE && now < q.resetAt) return q.resetAt - now; // proactive defer
return 0;
}Surfacing quota in the UI
The number you already track is the number the user wants to see. Bind remaining/limit to a badge (“42 / 100 this minute”) and resetAt to a countdown. When the gate is closed, disable the action and show “resets in 18s” rather than letting the click fail with a 429. This reuses the same disable/countdown machinery described in exponential backoff and UX.
| State | Gate result | UI |
|---|---|---|
| Plenty left | 0 |
Control enabled, badge shows count |
| Near reserve | 0 (still sends) |
Badge turns amber |
| At/below reserve | resetAt − now |
Control disabled, “resets in Ns” |
In 429 cooldown |
cooldownUntil − now |
Control disabled, cooldown countdown |
Distributed across tabs
Five tabs of the same app share one server-side budget but, by default, five independent client models — so each can think it has full quota and collectively blow it. Sharing the record makes the client honest. The mechanism (BroadcastChannel vs storage events vs SharedWorker), the SSR/iframe caveats, and the races are covered in persisting rate-limit state across tabs.
Failure modes & mitigations
- Optimistic over-send across tabs. Without sharing, N tabs each spend the full budget. Mirror to storage and broadcast updates.
- Stale record. A record from an old window over-restricts. Treat anything older than
staleMs, or pastresetAt, as unknown and probe. - Header absent. Not every endpoint emits
RateLimit-*. Degrade to reactive handling and theRetry-Afterpath from Retry-After parsing. - Clock skew on epoch reset. An absolute
-Resetdifferenced against a wrong client clock mis-times the gate; the seconds-remaining form avoids it. - Reserve too small. Concurrent in-flight requests can each pass the gate before any response lands. A
reserveof 1–2 absorbs that race.
Child topics
- Persisting Rate-Limit State Across Tabs — sharing a cooldown and remaining quota across tabs via BroadcastChannel, localStorage, and storage events, with the SSR/iframe and race caveats.
What the Client Should Store
Client-side rate-limit state is small, and choosing exactly what to keep prevents most of the bugs in this area.
An absolute instant, never a duration. Store “may send again at 1751899203780” rather than “wait 30 seconds”. A duration is meaningless after a reload, a tab suspension, or a device sleep, and the client that stores one ends up waiting the full period again from whenever it happens to wake.
The remaining allowance and where it came from. A local estimate derived from requests you sent is optimistic because other tabs, devices, and background jobs share the credential. When a response carries X-RateLimit-Remaining, that number supersedes the estimate — always downward, never upward, because the server sees consumption the client cannot.
The scope the state applies to. A pause for one route class should not stop unrelated requests. Key the state by whatever the server limits by: usually the credential, sometimes the credential plus a route class. Storing a single global pause is simple and occasionally over-restrictive; storing per-endpoint state is precise and easy to get wrong.
Nothing sensitive. This state travels through local storage, which is readable by any script on the origin. Store the instant and the count, never the credential itself.
Keeping the State Correct Across Contexts
The hard part is not the model, it is that a browser gives you several copies of it.
Tabs, workers, and iframes each run their own JavaScript context with its own memory. Without coordination, five tabs each believe they own the full budget and the server sees five times the intended rate — the same multiplication that per-node counters cause on a server fleet. Three coordination mechanisms are available, and they trade immediacy against durability. A storage event fires in other tabs when local storage changes, which is durable across reloads and arrives with a short delay. A broadcast channel delivers immediately but only to live contexts and loses everything on reload. A shared worker owns a single scheduler that every tab talks to, which is exact but unavailable in some embedded contexts.
The practical combination is storage for durability plus a broadcast channel for immediacy: write the instant to storage, announce it on the channel, and have every context apply whichever is later. When neither is available — an embedded webview, a strict privacy mode — fall back to dividing the budget by an estimate of concurrent contexts, which is conservative and never wrong in the dangerous direction.
Two failure modes deserve explicit handling. Stale state: a stored pause that has passed must be ignored rather than applied, so every read compares the stored instant with the current time. Clock changes: if the stored instant was derived from a server-supplied duration and the device clock jumps, the comparison can produce an implausible wait; clamping the computed remaining time to the original duration bounds the damage.
Surfacing State Without Alarming Users
Rate-limit state is only useful if it reaches the interface at the right moment. Three rules keep that from becoming noise.
Show nothing for waits under about a second — a request that will succeed shortly is a loading state, not an error. Show a countdown and a reason for longer waits, so a disabled control reads as “waiting” rather than “broken”. And show remaining allowance only where it helps the user act: a bulk-import screen benefits from “about 400 operations left this hour”, while a login form does not.
For applications where users routinely hit limits — data tools, admin consoles, anything with bulk operations — a small persistent indicator showing remaining quota and reset time turns an invisible constraint into a visible one, and users plan around it instead of filing tickets.
Testing State That Lives in a Browser
Rate-limit state is one of the few pieces of client code where the interesting behaviour only appears under conditions a development loop never produces: several tabs, a reload mid-wait, a suspended device, a server that starts rejecting. Four tests cover almost all of it.
A reload during a pause. Set a pause, reload the context, and assert the client still refuses to send until the instant passes. This catches the most common bug in the area — storing a duration rather than an instant — and it fails loudly when someone “simplifies” the model.
Two contexts sharing one budget. Open two tabs against a stub server, drive one past the limit, and assert the second stops sending. Without coordination this test fails immediately, which is the point: it is the only cheap way to notice that the budget is being multiplied.
A stale instant. Write a pause instant in the past and assert it is ignored rather than applied. Systems that compare durations instead of instants will happily wait again for a window that has already expired.
A clock jump. Move the system clock forward and backward and assert the client neither sends early nor locks itself out. Since stored instants are absolute, a backwards jump is the dangerous direction, and clamping to the original duration bounds it.
Run all four against a stub server that returns a known wait, rather than against a real API: the assertions are about the client’s model, and a real server introduces timing that makes the tests flaky for reasons unrelated to what they check.
Choosing How Much State to Keep
There is a temptation to model the server’s limiter exactly in the client — the algorithm, the window, the burst allowance — so the client can predict every decision. It is almost always the wrong trade.
The client cannot see other consumers of the same credential, cannot see the server’s clock, and cannot see limit changes that happen between deploys. A detailed local model is therefore confidently wrong in ways a simple one is not, and it must be updated whenever the server’s configuration changes, which couples a client release to a server change.
The version that ages well keeps three facts: when the client may send again, roughly how much allowance remains, and how confident it is in that estimate. Everything else is read from the response headers as they arrive. That model is small enough to serialise into storage, simple enough to reason about across tabs, and — crucially — degrades to “send and see what happens” when the server publishes nothing, which is exactly what you want from a fallback.
Integrating With Application State
The last question is where this state lives relative to the rest of the application, and the answer that scales is: outside it.
Rate-limit state is transport state. It describes the connection between this client and one API, not anything about the user’s data, and putting it in the same store as domain state creates two problems. Components begin subscribing to it directly, which spreads status-code awareness through the interface. And it gets serialised, persisted, and rehydrated alongside domain state, which is how a stale pause survives into a session where it no longer applies.
Keeping it in the transport layer — a module owning the wrapper, the scheduler, and the queue — and exposing a small read-only view to the interface avoids both. The view needs three fields: whether sending is currently paused, when it resumes, and how much allowance remains if the server publishes it. A single subscribable object with those fields is enough to drive every indicator an application needs, and it keeps the rest of the code free of any notion of HTTP status.
The same boundary makes testing tractable. The transport module can be exercised against a stub server with no rendering involved, and the interface can be exercised against a fake view object with no network involved. Applications that entangle the two end up testing neither, because every test needs both a server and a renderer.
One integration detail is worth calling out: server-driven state should update the view even when no component asked for it. A background sync that receives a rejection has learned something the whole interface needs, and the indicator should reflect it immediately rather than waiting for the next user-initiated request to rediscover the same condition.
A Minimal Model Worth Copying
If you take one thing from this guide, take the shape of the state itself. Three fields, one per credential scope, updated from two sources.
resumeAt is an absolute instant, written whenever the server tells the client to wait, and compared against the current time on every send decision. remaining is an integer, taken from the server’s published count when available and decremented locally between responses, always reconciled downward. confidence is a small enum — server, estimated, unknown — that tells the scheduler how much to trust remaining and tells the interface whether to display it at all.
That model fits in a few lines, serialises cleanly into storage, survives being shared between tabs, and degrades gracefully: with confidence: unknown the client simply sends and reacts, which is exactly the behaviour you want when an API publishes nothing. Everything else in this guide — the coordination mechanisms, the interface rules, the tests — exists to keep those three fields correct.
Applications that outgrow it usually do so in one direction: per-route-class scoping, because the server limits some endpoints more tightly than others. Adding a scope key to the same three fields covers that without changing anything else, which is a good sign the model is the right size.
The deep dive below covers the hardest part of this model in practice: keeping one budget consistent across several browser contexts that cannot see each other’s memory.
Keep the model small enough that a new engineer can hold it in their head, and the coordination will stay correct through the refactors that follow.
Related
- Frontend Resilience & UX Handling — the parent area for client-side rate-limit behavior.
- Retry-After Parsing — the reactive complement when you do hit a 429.
- Exponential Backoff & UX — disable/countdown UX shared with the gate.
- Handling 429 HTTP Responses — the broader response-handling guide.