Handling 429 in Fetch and Service Workers
fetch resolves successfully on a 429 β the promise does not reject, response.ok is false, and code that only catches network errors treats a rate limit as a successful empty result. Centralising that handling, and deciding carefully what a service worker may do about it, is the practical core of handling 429 HTTP responses in a modern browser application.
The problem in concrete numbers
A dashboard makes 40 requests on load. Twelve return 429. Because fetch resolved, twelve components call response.json(), receive an error body shaped nothing like their expected payload, and render either empty state or a thrown exception in a component boundary. The user sees a page that is half-broken with no explanation, and the console shows twelve unrelated type errors rather than one rate-limit message.
Add a service worker that retries failed requests, and the twelve become thirty-six β sent from a context the page cannot see, against an API that has just asked the client to slow down.
What each layer may do
| Layer | May detect a 429 | May retry | May delay | Must not |
|---|---|---|---|---|
fetch wrapper |
Yes β this is its job | Yes, once, with backoff | Yes | Swallow the error |
| Data-fetching library | Yes, via the wrapperβs error | Yes, per its policy | Yes | Retry in addition to the wrapper |
| Service worker | Yes | No | Yes, by pausing | Retry invisibly to the page |
| Component code | No | No | No | Inspect status codes at all |
The service worker row is the one that surprises people. A worker sits outside the page, has no user context, and cannot know whether the request still matters. A worker that retries turns one user-visible failure into several invisible ones. What it can usefully do is pause: once the origin says βslow down,β the worker refuses to forward further requests to it until the window passes, and answers from cache where it can.
Step-by-step implementation
// api.ts β the only place that inspects a response status.
export class RateLimitError extends Error {
constructor(public retryAfterMs: number, public url: string) {
super(`Rate limited on ${url}; retry in ${Math.ceil(retryAfterMs / 1000)}s`);
this.name = "RateLimitError";
}
}
const MAX_WAIT_MS = 5 * 60_000;
const pausedUntil = new Map<string, number>(); // origin -> epoch ms
/** Retry-After is either delta-seconds or an HTTP-date; handle both. */
export function parseRetryAfter(value: string | null): number {
if (!value) return 0;
const seconds = Number(value.trim());
if (Number.isFinite(seconds)) return Math.min(MAX_WAIT_MS, Math.max(0, seconds * 1000));
const when = Date.parse(value);
if (Number.isNaN(when)) return 0;
return Math.min(MAX_WAIT_MS, Math.max(0, when - Date.now()));
}
export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const origin = new URL(url, location.href).origin;
// Another caller was already told to wait: do not add to the pile.
const until = pausedUntil.get(origin) ?? 0;
if (Date.now() < until) throw new RateLimitError(until - Date.now(), url);
const res = await fetch(input, init);
if (res.status !== 429) {
if (res.ok) pausedUntil.delete(origin);
return res;
}
// Fall back to one second rather than zero: a 429 with no header still means wait.
const waitMs = parseRetryAfter(res.headers.get("Retry-After")) || 1000;
pausedUntil.set(origin, Date.now() + waitMs);
throw new RateLimitError(waitMs, url);
}// sw.js β the worker pauses an origin; it never retries on the page's behalf.
const PAUSE_KEY = "ratelimit-pause";
const pauses = new Map(); // origin -> epoch ms (in-memory, per worker)
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== self.location.origin && !url.pathname.startsWith("/v1/")) return;
event.respondWith((async () => {
const until = pauses.get(url.origin) ?? 0;
if (Date.now() < until) {
// Paused: prefer a cached answer, otherwise tell the page honestly.
const cached = await caches.match(event.request);
if (cached) return cached;
return new Response(
JSON.stringify({ error: "rate_limited", source: "service-worker" }),
{ status: 429, headers: {
"Content-Type": "application/json",
"Retry-After": String(Math.ceil((until - Date.now()) / 1000)),
} },
);
}
const res = await fetch(event.request);
if (res.status === 429) {
const header = res.headers.get("Retry-After");
const seconds = Number(header);
const waitMs = Number.isFinite(seconds)
? seconds * 1000
: Math.max(0, Date.parse(header ?? "") - Date.now()) || 1000;
pauses.set(url.origin, Date.now() + waitMs);
// NOTE: no retry here. The page decides whether the request still matters.
}
return res;
})());
});Gotchas and edge cases
response.okis false but the promise resolved. Any code doingawait fetch(...).then(r => r.json())will parse an error body as data. The wrapper exists to make that impossible.Retry-After: 0or a missing header. Both should become at least one second in the client, or you produce a tight retry loop against a server that just asked for room.- HTTP-date parsing with a skewed client clock. A date-form
Retry-Afteris interpreted against the browserβs clock; clamp the result to a plausible range so a wrong clock cannot produce an hours-long or negative wait. - Service worker retries. They are invisible to the page, cannot be cancelled by a navigation, and multiply load exactly when the server is shedding it.
- Pause state lost on worker restart. Service workers are terminated aggressively; an in-memory pause map resets. For a short window that is acceptable; persist to a cache entry if your windows are minutes long.
- Pausing the wrong scope. Pausing by origin stops unrelated endpoints too. If your limits are per route class, key the pause by class rather than by origin.
- Two retry layers. A wrapper that retries plus a data library that retries produces four requests per user action. Pick one layer and disable the other.
Verification and testing
// api.test.js β the wrapper's contract, with fetch stubbed.
import { describe, it, expect, vi } from "vitest";
import { apiFetch, parseRetryAfter, RateLimitError } from "./api";
it("parses both Retry-After forms", () => {
expect(parseRetryAfter("120")).toBe(120_000);
const inTen = new Date(Date.now() + 10_000).toUTCString();
expect(parseRetryAfter(inTen)).toBeGreaterThan(8_000);
expect(parseRetryAfter(null)).toBe(0);
expect(parseRetryAfter("nonsense")).toBe(0);
});
it("throws a typed error rather than returning the response", async () => {
vi.stubGlobal("fetch", async () => new Response("{}", {
status: 429, headers: { "Retry-After": "3" },
}));
await expect(apiFetch("/v1/data")).rejects.toBeInstanceOf(RateLimitError);
});
it("short-circuits other callers while the origin is paused", async () => {
const spy = vi.fn(async () => new Response("{}", { status: 429, headers: { "Retry-After": "5" } }));
vi.stubGlobal("fetch", spy);
await expect(apiFetch("/v1/a")).rejects.toThrow(RateLimitError);
await expect(apiFetch("/v1/b")).rejects.toThrow(RateLimitError);
expect(spy).toHaveBeenCalledTimes(1); // the second never hit the network
});# End-to-end: confirm the worker stops forwarding once the origin says 429.
# In DevTools > Application > Service Workers, then in the console:
# for (let i = 0; i < 200; i++) fetch('/v1/ping');
# Network panel expectation: a burst, one 429 from the server, then
# synthetic 429s served by the service worker with no further network rows.The console exercise is the quickest way to prove the pause works: after the first server-issued 429, the Network panel should show no further requests leaving the browser until the window passes.
Frequently Asked Questions
Why does fetch not reject on a 429?
Because the request succeeded at the network level β a response arrived. `fetch` rejects only for network failures, so every HTTP error status, including 429, resolves normally with ok set to false. Any client that does not check the status treats a rate limit as data.
Should a service worker retry rate-limited requests?
No. The worker has no user context, cannot know whether the request still matters, and its retries are invisible to the page and uncancellable by a navigation. It should pause the origin, serve cache where it can, and let the page decide whether to try again.
What should the client do when a 429 has no Retry-After?
Wait at least one second and apply its own exponential backoff with jitter from there. Zero and missing are the same case in practice, and treating either as "retry now" produces the tight loop the server was trying to stop.
Should the pause apply to the whole origin?
Only if your limits are origin-wide. Where limits are per route class, key the pause by class so a rejected export request does not stop unrelated reads. Origin-wide pausing is the safe default when you do not know how the server scopes its limits.
Related
- Handling 429 HTTP Responses β the parent guide on client-side rate limit handling.
- Retry-After Parsing β the headerβs two encodings in detail.
- SDK & Client-Side Throttling β pacing so these rejections stop happening.
- Persisting Rate Limit State Across Tabs β sharing the pause between contexts.