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.

JavaScript
Frontend
4 snippets
http
error-handling
code-template
utility
leoeriksson

By @leoeriksson

April 13, 2026

·

Updated August 12, 2026

1,034 views

32

4.2 (9)

// apiFetch v1: timeout + JSON parse + structured error.
// Stage 1 of 4. Each stage is independently runnable.

class ApiError extends Error {
    constructor(status, code, body) {
        super(`api ${status} ${code}`);
        this.status = status;
        this.code = code;
        this.body = body;
    }
}

async function apiFetch(url, opts = {}) {
    const { timeoutMs = 8000, headers = {}, ...rest } = opts;
    const ac = new AbortController();
    const timer = setTimeout(() => ac.abort(), timeoutMs);
    try {
        const res = await fetch(url, {
            ...rest,
            signal: ac.signal,
            headers: { accept: 'application/json', ...headers },
        });
        const text = await res.text();
        const body = text ? safeParseJson(text) : null;
        if (!res.ok) throw new ApiError(res.status, body && body.code, body);
        return body;
    } finally {
        clearTimeout(timer);
    }
}

function safeParseJson(text) {
    try { return JSON.parse(text); } catch { return { raw: text }; }
}

// Demo against a mocked fetch.
globalThis.fetch = async () => ({
    ok: true, status: 200,
    text: async () => JSON.stringify({ id: 7, ok: true }),
});
apiFetch('/users/7').then((b) => console.log('got:', b));

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.