Community JavaScript Snippet
The Fetch Wrapper I Keep in Every Project
My zero-dep `apiFetch` for Node and the browser. Adds a per-request timeout, retries with jittered backoff on 5xx and network failures, parses JSON, and attaches an auth token without leaking it into errors.
The Fetch Wrapper I Keep in Every Project
My zero-dep `apiFetch` for Node and the browser. Adds a per-request timeout, retries with jittered backoff on 5xx and network failures, parses JSON, and attaches an auth token without leaking it into errors.
By @leoeriksson
April 13, 2026
·
Updated May 18, 2026
1,033 views
32
4.2 (9)
This is the smallest version of the wrapper I will tolerate in a service. The AbortController plus setTimeout pair is the only portable way to bound a fetch call in both Node 18+ and browsers, and clearTimeout in finally matters: if you forget it, an abort fires after the request already returned and you get spurious noise in your logs. safeParseJson exists because real APIs return HTML error pages on 502, and a top-level JSON.parse throw is much harder to debug than a { raw: '...' } payload. The custom ApiError carries status and code separately so callers can branch on err.status === 429 without string-matching the message.
Full jitter (uniform random in [0, baseDelay * 2^attempt)) is the backoff I default to because it spreads the thundering herd better than equal jitter when many clients retry the same outage. The retry rule is deliberately narrow: 5xx and network errors only, never 4xx. A retried 401 is just an extra log line; a retried 429 is a way to get rate-limit-banned faster. The lastErr shuffle is so the loop's final throw carries the most recent failure rather than whatever the first attempt threw, which is what an on-call engineer wants to see when they open the trace.
The shape here is a factory that closes over getToken so the token never lives in a module-level variable. The two careful bits are the authorization header being added last in the spread (callers cannot stomp it accidentally) and the explicit scrubAuth pass before attaching headers to the thrown error. I learned to do the second the hard way after a Sentry breadcrumb captured a Bearer token because we attached init.headers straight to the exception. Keeping the secret out of the error means you can paste a stack trace into Slack without a panic afterward.
Tying the three concerns together gives the shape I actually paste into a lib/api.ts file in every Node service. The factory returns a function so you can have one apiFetch per upstream (different baseUrl, different getToken), which is the configuration shape that scales when you start calling Stripe and your own backend from the same process. Defaults are merged-then-overridden so a single endpoint can opt out of retries by passing retries: 0 for non-idempotent POSTs without re-implementing the wrapper. About 70 lines, no dependencies, and it survives every refactor I throw at it.
