Release candidate — 1.0.0-rc.7
← Back to blog

How to Retry a Failed Fetch in TypeScript (the Right Way)

Oleksandr Zhuravlov

A call to fetch fails once with a 503, the data was there a second ago, and a single retry would have saved the request — but native fetch has no retry, so the error surfaces straight to your caller. You reach for the obvious fix: wrap it in a loop. That loop is where most retry code goes wrong, because retrying correctly means deciding what to retry, how long to wait, and when to stop — and the naive version answers all three badly.

Why fetch gives you nothing

fetch rejects only on a network error — DNS failure, dropped connection, an aborted signal. A 429 or a 503 is a perfectly resolved promise with response.ok === false. So before you can retry anything you have to decide what counts as a failure, because the runtime won't tell you. That's the first thing a retry has to get right and the first thing a try/catch around fetch gets wrong: it never fires for the status codes you most want to retry.

The loop everyone writes first

Here's the version that shows up in most codebases. It runs, and it's better than nothing:

async function (: string,  = 3): <Response> {
    let : unknown;
    for (let  = 0;  < ; ++) {
        try {
            const  = await ();
            if (.) return ;
             = new (`HTTP ${.}`);
        } catch () {
             = ;
        }
    }
    throw ;
}

It loops, it catches both rejection modes, it surfaces the last error. And it's wrong in four ways that only show up under load or against a misbehaving server — exactly when you needed the retry to be right.

The four pitfalls

No backoff means a thundering herd. The loop above retries with no wait at all, hammering a struggling server as fast as the event loop allows. Add a fixed delay and you've fixed the rate but not the synchronization: when a dependency blips, every client that was mid-call retries on the same schedule, so the recovering server gets a synchronized wall of traffic on each tick. The fix is exponential backoff — double the wait each attempt — plus jitter, a random spread on each delay so a thousand clients don't line up on the same millisecond.

Retrying a 4xx that will never succeed. A 400 is a malformed request; a 401 is a missing credential; a 404 is a wrong path. None of them get better by trying again — retrying only delays the inevitable error by three round-trips and adds load for nothing. Retry the transient set (429, 502, 503, 504) and let the rest fail fast.

Retrying a non-idempotent write. This is the dangerous one. A POST that creates an order can time out after the server committed the write but before the response reached you. Retry it and you've created two orders. A retry policy that doesn't know which calls are safe to repeat will silently double-apply side effects under exactly the network conditions that triggered the retry. Reads are safe; blind writes are not.

Ignoring Retry-After. When a server returns a 429 or a 503 it often tells you precisely when to come back, in a Retry-After header — either delta-seconds or an HTTP-date. Your computed backoff is a guess; that header is the answer. Ignoring it means you retry too early (and get rate-limited again) or too late (and wait longer than asked).

A correct-ish hand-rolled retry

Fold all four in and the helper grows teeth. This one backs off exponentially with jitter, retries only a transient set, honors Retry-After, and stays a GET so repeating it is safe:

const  = new ([429, 502, 503, 504]);

function (: Response): number | null {
    const  = ..('retry-after');
    if (!) return null;
    const  = ();
    if (!.()) return  * 1000;
    const  = .();
    return .() ? null : .(0,  - .());
}

async function (
    : string,
    {  = 3,  = 200,  = 10_000 } = {},
): <Response> {
    let : unknown;
    for (let  = 0;  < ; ++) {
        try {
            const  = await ();
            if (.) return ;
            if (!.(.)) return ; // dead 4xx — fail fast
             = new (`HTTP ${.}`);

            if ( <  - 1) {
                const  = ();
                const  = .(,  * 2 ** );
                const  = .() * ;
                await new (() => (,  ?? ));
            }
        } catch () {
             = ;
            if ( <  - 1) {
                await new (() =>
                    (
                        ,
                        .() * .(,  * 2 ** ),
                    ),
                );
            }
        }
    }
    throw ;
}

That's the honest minimum, and it's a lot more than a for loop. It also still doesn't compose with a timeout (a hung attempt blocks the whole budget), it doesn't bound the total time across attempts, and the moment you have a second endpoint you copy-paste it or thread the differences through arguments. The logic that decides whether to retry is now tangled into the function that makes the call.

The declarative version

The retry policy isn't really code — it's data: a count, a status set, a backoff curve, a flag for Retry-After. A stitch lets you say exactly that data and keeps the deciding logic out of your call site. You declare the call once, with its retry policy as a field:

import {  } from 'stitchapi';

const  = ({
    : 'https://api.example.com',
    : '/users/{id}',
    : {
        : 3,
        : [429, 503],
        : { : 'expo-jitter', : 200 },
    },
});

const  = await ({ : { : '42' } }); // retried, backed off, Retry-After honored

Every pitfall above is now a named field. attempts is the total number of tries including the first, so attempts: 3 means up to two retries. on lists the status codes that trigger a retry — defaulting to the transient set [429, 502, 503, 504], which is what keeps a dead 4xx from being retried at all. backoff: 'expo-jitter' doubles the delay each attempt and spreads it randomly to break up the herd, paced from backoff.base and clamped to backoff.max; 'expo' and 'fixed' are there when you want a different curve. And when the response carries a Retry-After header, the server's number is used in place of the computed backoff — the default, since it beats guessing; respect: false turns it off.

The non-idempotent-write pitfall doesn't vanish — nothing can make a blind POST retry safe — but it stops being a silent default. Adding retry to a write is a deliberate line you write, and the retry guide flags it: pair it with an idempotency key so the server collapses the duplicate, or leave retry off. The policy is declared where you can see it, not buried in a shared helper three files away.

Because the policy lives on the definition, it composes instead of tangling. Each retry surfaces as a progress event on the event stream, so a trace shows every wait and re-attempt rather than a silent stall inside a loop. And the same declaration takes a timeout next to retry — a per-attempt budget and a total budget that the hand-rolled version never bounded — which is the composition the circuit breakers and layered timeouts post walks through end to end.

Start with the helper — reach for a stitch when the policy spreads

The dozen lines above are a fine floor. For a single internal GET that flakes once a month they're clearer than a dependency, and a run-once script never meets the thundering herd that jitter exists to break up. What the helper can't do is stay in one place — the moment the rule has to hold the same way across a second call site, it gets copy-pasted, and the copies drift:

  • A second endpoint needs the same policy. "Retry the transient set, back off with jitter, honor Retry-After, and don't you dare retry that POST" is now duplicated logic, each copy free to fall out of step with the others.
  • You want the policy visible, not trusted. Buried in a shared helper three files away, retry is something a reader takes on faith. On the declaration it's a field they can see at the call site.
  • The policy gains a neighbor. The same definition takes a timeout next to retry — a per-attempt and total budget the loop never bounded — and surfaces each attempt as a progress event instead of a silent stall.

That's the line, and crossing it is a field, not a rewrite: the same call gains retry declared once, applied by the engine rather than by convention. A stitch is the helper you'd graduate to anyway — it just starts where the helper tops out. If you're arriving here from axios interceptors rather than raw fetch, the same trade is mapped out in axios alternatives.

Try it

npm install stitchapi@rc

Declare one endpoint with a retry field and the loop, the backoff math, the status-code allowlist, and the Retry-After parsing collapse into five lines of policy you can read at a glance. The full option list — every backoff curve plus its base and max bounds, and the idempotency caveat — lives in Retry & backoff.