Persisting Rate-Limit State Across Tabs

The task is making a 429 cooldown and a remaining-quota count shared across every open tab of your app, so one tab hitting the limit immediately throttles the others instead of each tab learning the hard way. This sits under client-side rate-limit state, and it’s the difference between a client that respects the server’s budget and one that multiplies it by however many tabs the user happens to have open.

The problem in concrete numbers

A user opens your analytics app in 6 tabs. The API allows 100 requests/minute per account. Each tab polls every 5 s — roughly 12 requests/minute per tab — and individually stays under 100, so each tab’s local quota model is happy. Collectively they spend 72 requests/minute, and when a manual refresh storm pushes the account to its limit, tab #1 gets a 429 with Retry-After: 30. Without shared state, tabs #2–#6 keep firing straight into the cooldown, burning the entire 30 s window on rejected requests. Sharing one cooldown record means tab #1’s 429 silences all six tabs for 30 s — one penalty, not six tabs’ worth of wasted retries.

Comparison: BroadcastChannel vs storage events vs SharedWorker

Mechanism How it propagates Latency Survives reload Same-origin scope Browser support Best for
BroadcastChannel Direct pub/sub between contexts Lowest (event loop) No (in-memory) Same origin Modern evergreen Live updates between open tabs
localStorage + storage event Write fires storage in other tabs Low Yes (persisted) Same origin Universal Persistence + cross-tab notify; warm new tabs
SharedWorker One worker holds canonical state Low No (worker memory) Same origin No Chrome-Android / older Safari gaps Single source of truth, serialized writes

The pragmatic production answer is both BroadcastChannel and localStorage together: localStorage is the durable mirror that warms a freshly opened tab and fires storage events as a fallback, while BroadcastChannel delivers the low-latency live push to already-open tabs. SharedWorker is the cleanest model where supported but its gaps force a fallback anyway, so most apps skip it.

Sharing a cooldown record across tabs One tab receives a 429, writes the cooldown to localStorage and posts to a BroadcastChannel, and the other tabs adopt the shared cooldown. Tab A gets 429 cooldown 30s localStorage rl:state (durable) + BroadcastChannel Tab B defers send Tab C defers send write notify

Step-by-step implementation

  • Guard every window/localStorage/BroadcastChannel
  • On a 429, write a cooldown record (cooldownUntil, resetAt) to localStorage
  • Post the same record on a BroadcastChannel
  • Listen to both BroadcastChannel message and the storage
  • On new-tab boot, hydrate from localStorage
// shared-cooldown.ts — cross-tab 429 cooldown via BroadcastChannel + localStorage.
interface Shared { cooldownUntil: number; remaining: number; resetAt: number; updatedAt: number; }
const KEY = "rl:state:v1";
const hasWindow = typeof window !== "undefined"; // SSR guard

let state: Shared = hydrate();
const bc = hasWindow && "BroadcastChannel" in window ? new BroadcastChannel(KEY) : null;

function hydrate(): Shared {
  const empty: Shared = { cooldownUntil: 0, remaining: Infinity, resetAt: 0, updatedAt: 0 };
  if (!hasWindow) return empty;
  try { return { ...empty, ...JSON.parse(localStorage.getItem(KEY) ?? "{}") }; }
  catch { return empty; }
}

// Adopt a peer's record only if it is newer than ours (last-writer-wins).
function adopt(next: Shared) {
  if (next && next.updatedAt > state.updatedAt) state = next;
}

if (bc) bc.onmessage = (e) => adopt(e.data as Shared);
if (hasWindow) {
  window.addEventListener("storage", (e) => {       // fallback + cross-tab persistence
    if (e.key === KEY && e.newValue) adopt(JSON.parse(e.newValue));
  });
}

// Call when a 429 lands — fans the cooldown out to every tab.
export function recordCooldown(ms: number, resetAt: number) {
  state = { ...state, cooldownUntil: Date.now() + ms, resetAt, updatedAt: Date.now() };
  if (hasWindow) localStorage.setItem(KEY, JSON.stringify(state)); // fires storage in others
  bc?.postMessage(state);                                          // instant push to open tabs
}

// Call before sending — ms to wait (0 = clear). Shared across all tabs.
export function cooldownRemainingMs(): number {
  return Math.max(0, state.cooldownUntil - Date.now());
}

Gotchas & edge cases

  • SSR has no window. Next.js/Remix render this module on the server, where localStorage and BroadcastChannel are undefined. Guard with typeof window !== "undefined" and lazy-init, or the build/hydration throws.
  • The storage event does not fire in the tab that wrote it. Only other tabs receive it — which is exactly what you want, but it means you can’t rely on storage for same-tab updates; update local state directly on write.
  • Cross-origin iframes are isolated. An iframe on a different origin gets its own localStorage and BroadcastChannel namespace, so it won’t share the parent’s cooldown. Same-origin iframes do share. Use postMessage if you must bridge origins.
  • Write races / last-writer-wins. Two tabs writing near-simultaneously can clobber each other. The updatedAt timestamp and adopt guard pick the newer record; for stricter ordering, funnel writes through a SharedWorker.
  • JSON quota corruption. A truncated/garbage localStorage value must not crash boot — wrap JSON.parse in try/catch and fall back to empty state.
  • Private-mode storage caps. Some browsers throw on localStorage.setItem in private mode; treat write failures as non-fatal and keep the in-memory copy.

Verification & testing

Open two tabs of the app, trip a 429 in one, and confirm the other defers without sending. Programmatically, simulate the storage event:

import { recordCooldown, cooldownRemainingMs } from "./shared-cooldown";

recordCooldown(30_000, Date.now() + 30_000);
console.assert(cooldownRemainingMs() > 29_000, "cooldown active locally");

// Simulate a peer tab writing a newer record via the storage event:
const peer = { cooldownUntil: Date.now() + 45_000, remaining: 0, resetAt: 0, updatedAt: Date.now() + 1 };
localStorage.setItem("rl:state:v1", JSON.stringify(peer));
window.dispatchEvent(new StorageEvent("storage", {
  key: "rl:state:v1", newValue: JSON.stringify(peer),
}));
console.assert(cooldownRemainingMs() > 44_000, "adopted newer peer cooldown");

To watch it live, log the BroadcastChannel in DevTools across two tabs:

# In each tab's console, then trigger a 429 in one and observe the other:
# new BroadcastChannel("rl:state:v1").onmessage = (e) => console.log("peer", e.data)

Frequently Asked Questions

BroadcastChannel or storage events — which should I use?

Use both. BroadcastChannel gives the lowest-latency live push between open tabs, while localStorage persists the record so a newly opened tab boots warm and the storage event acts as a fallback where BroadcastChannel is unavailable.

Why doesn't my own tab receive the storage event?

By spec the storage event only fires in *other* same-origin tabs, never the one that performed the write. Update your in-memory state directly when you write, and rely on the event purely to notify the other tabs.

Does this work inside an iframe?

Only for same-origin iframes, which share localStorage and the BroadcastChannel namespace with the parent. Cross-origin iframes are isolated and need an explicit postMessage bridge to share the cooldown.

How do I avoid two tabs clobbering the shared record?

Stamp every write with an updatedAt timestamp and only adopt an incoming record if it is newer (last-writer-wins). For strict serialization, route all writes through a single SharedWorker that owns the canonical state.