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.

Where a 429 is noticed in a fetch-based application Without a wrapper, each caller parses the error body as data and fails in its own way; with a wrapper, the rate limit is detected once, converted into a typed error, and surfaced through a single path. fetch resolves on 429 β€” somebody has to notice no wrapper 12 callers parse an error body 12 different failures no Retry-After read user sees a broken page one wrapper status checked once typed RateLimitError thrown Retry-After parsed and stored one message, one recovery path the wrapper is the only place that should ever inspect a status code Responsibilities of each client layer The transport wrapper detects the rejection and owns retries, the service worker may only pause an origin and serve cache, and component code should never inspect a status code at all. Who does what after a rejection transport wrapper detects the 429 one retry policy typed error out service worker pauses the origin serves cache never retries component code renders a state no status codes no retries a worker that retries produces load the page cannot see or cancel

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

typescript
// 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);
}
javascript
// 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;
  })());
});
Service worker behaviour while an origin is paused During the pause window the worker answers from cache where possible and otherwise returns a synthetic rate-limited response carrying the remaining wait, and it never forwards new requests or retries on the page's behalf. The worker's job is to stop traffic, not to replay it page requests /v1/data while paused worker checks the pause no network call cache hit: serve it no cache: synthetic 429 with the remaining wait either way the page learns immediately and can tell the user

Gotchas and edge cases

  • response.ok is false but the promise resolved. Any code doing await fetch(...).then(r => r.json()) will parse an error body as data. The wrapper exists to make that impossible.
  • Retry-After: 0 or 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-After is 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

javascript
// 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
});
bash
# 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.