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 (from RateLimit-Remaining / X-RateLimit-Remaining).
  • limit — the window’s ceiling (RateLimit-Limit).
  • resetAt — epoch ms when remaining refills (RateLimit-Reset, normalized).
  • cooldownUntil — epoch ms set when a 429 lands, 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.

Proactive client throttle from RateLimit headers Responses update a local quota record of remaining, limit, and reset; outgoing requests check the record and either send or defer until reset. API response RateLimit-* local quota remaining / limit resetAt / cooldown gate: remaining > reserve? send or defer→reset UI badge "42 left · 30s"

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.

typescript
// 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
Sharing one budget across browser contexts Independent tabs each assume the full budget and multiply the request rate, a divided budget is safe but wastes headroom when tabs idle, and a leader-elected scheduler uses the whole budget exactly once. Three ways several tabs can share a key independent tabs each assumes the full budget rate multiplied by tab count rejections in every tab divided budget safe by construction idle tabs waste headroom no messaging needed leader election one scheduler for all tabs full budget, used once handover on close the same multiplication problem as per-node counters, moved onto the user machine

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.

Local estimate against the server-reported remaining count The local estimate ignores other tabs, devices, and background jobs sharing the credential, so reconciling downward to the value the server reports keeps the client from over-sending. Two views of the same allowance local estimate fast, no round trip ignores other consumers always optimistic server-reported accounts for everything arrives with each response take the smaller value trusting the local number over the header is how a well-paced client still gets rejected

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 past resetAt, as unknown and probe.
  • Header absent. Not every endpoint emits RateLimit-*. Degrade to reactive handling and the Retry-After path from Retry-After parsing.
  • Clock skew on epoch reset. An absolute -Reset differenced 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 reserve of 1–2 absorbs that race.

Child topics

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.