JavaScript Fetch API: 3 Patterns Beyond response.json()

2026-06-06 · Nico Brandt

The fetch call you’ve shipped a thousand times is fetch(url).then(r => r.json()).then(doThing). It works. Until the response is 2 GB, the user navigates away mid-request, or the upstream hands you a 503 because someone pushed a bad config.

Three javascript fetch api patterns turn that one-liner into something that survives production: streaming with ReadableStream, AbortController for cancellation, and retry with exponential backoff. Each is useful alone. They get interesting when they compose — a streaming fetch you can abort that retries on transient failures.

So what have you been missing?

Pattern 1: Streaming Responses with ReadableStream

response.json() waits. It buffers the entire response, parses it, then hands you an object. That’s fine for a 4 KB API call. It’s a problem the moment the response is large, slow, or — most interestingly in 2026 — incremental.

response.body is a ReadableStream. You can iterate it as bytes arrive. No getReader(), no while (true), no manual pump loop. Modern browsers have supported async iteration on ReadableStream since 2023. The ReadableStream fetch API gives you a byte-level firehose you tap incrementally:

const response = await fetch(url);
for await (const chunk of response.body) {
  // chunk is a Uint8Array
}

The real fetch api streaming response use case isn’t a download progress bar — it’s NDJSON endpoints and LLM token streams from OpenAI or Anthropic. If you’re building with AI APIs in production, streaming is where fetch really earns its keep over the SDK defaults. Here’s a generator that yields parsed objects as they arrive:

async function* streamNdjson(url, options) {
  const response = await fetch(url, options);
  const decoder = new TextDecoder();
  let buffer = '';
  for await (const chunk of response.body) {
    buffer += decoder.decode(chunk, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop(); // keep the partial line for next chunk
    for (const line of lines) {
      if (line) yield JSON.parse(line);
    }
  }
  if (buffer) yield JSON.parse(buffer);
}

The buffer = lines.pop() line is the trick. Chunks don’t respect your JSON boundaries. A single object can land split across two reads, so you hold the tail until the next newline arrives. Skip this and you’ll ship code that works in dev and explodes the moment the network slows down.

If your data is actually event-shaped — server pushing discrete messages over time — server-sent events are usually a better fit than a raw streaming fetch. NDJSON streaming earns its keep when the server’s already an HTTP endpoint and the data is a series of records, not events.

When NOT to stream: if the response fits in memory and you need the whole thing anyway, response.json() is shorter, safer, and just as fast. Streaming pays off when first-byte latency matters — real-time data, large files, anything where the user benefits from seeing something before everything has arrived.

Honest take: streaming is the pattern devs reach for and regret most often. The buffer-the-tail dance, the TextDecoder, the partial-parse error handling — it all adds up. Reach for it when the data is genuinely incremental, not because it sounds cooler than await response.json().

Streaming works great until the user closes the tab three seconds into a thirty-second stream. Your code keeps pulling bytes. How do you actually stop a fetch?

Pattern 2: Cancellation and Timeouts with AbortController

fetch has no built-in timeout. None. A request will hang as long as the OS keeps the socket open — minutes, sometimes effectively forever. Every “why is my page stuck” bug I’ve debugged in the last year traces back to someone assuming fetch would eventually give up.

AbortController is the answer. The shape: a controller owns an AbortSignal, you pass the signal into fetch, and calling controller.abort() rejects the fetch promise with an AbortError. Three abortcontroller fetch javascript patterns actually earn their place in production code.

Pattern 2a — javascript fetch timeout in one line:

const response = await fetch(url, { signal: AbortSignal.timeout(5000) });

AbortSignal.timeout(ms) is a modern helper that does what the old setTimeout(() => controller.abort(), ms) dance did, in a fraction of the code. Use it. The manual version is only useful when you need to cancel the timeout based on other events.

Pattern 2b — React useEffect cleanup:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/user/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, [id]);

The cleanup function fires when the component unmounts or id changes. Without it, a stale request can land after a new one and overwrite fresh state with old data. The AbortError branch matters: a deliberate cancel is not an error to log. Skip that check and every route change paints a red error toast.

Pattern 2c — abort during streaming:

The same signal that cancels a pending fetch also tears down an active ReadableStream read. The for await loop just throws AbortError, which you catch like any other failure:

try {
  for await (const obj of streamNdjson(url, { signal })) {
    handle(obj);
  }
} catch (err) {
  if (err.name !== 'AbortError') throw err;
}

Always branch on err.name === 'AbortError'. It’s the one error in your fetch code that means “things went exactly as planned.” Treat it as a first-class control flow path, not a failure mode.

When abort is overkill: a single GET that resolves in 50 ms on a button click does not need cancellation. AbortController earns its keep when there’s a real chance of stale results — search-as-you-type, route changes, long-lived streams, anything where the answer might no longer matter by the time it arrives.

Now your fetch can stop on demand. But the requests that fail aren’t always the ones the user abandoned. Some fail because the upstream is having a bad five seconds.

Pattern 3: Retry with Exponential Backoff

The 99th percentile of fetch failures isn’t your code. It’s a transient 502, a rate-limit 429, a flaky DNS lookup, an upstream that just restarted. None of those are bugs you can fix. All of them are blips that a fetch api retry pattern can hide.

The rules first. Get these wrong and retry makes things worse:

The backoff math is exponential with jitter:

delay = base * 2^attempt + random_jitter

Base of 200–500 ms, max 3–5 attempts, cap the per-attempt delay around 10 seconds. Jitter is not optional. Without it, every client that hit the same outage retries at exactly the same moments and finishes off whatever service was already struggling. The thundering herd is a real thing, and AWS has the postmortems to prove it.

A working wrapper:

async function fetchWithRetry(url, options = {}, { retries = 3, base = 300 } = {}) {
  const method = options.method ?? 'GET';
  const idempotent = ['GET', 'HEAD', 'OPTIONS'].includes(method);
  const retryable = new Set([408, 429, 502, 503, 504]);

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url, options);
      if (response.ok || !retryable.has(response.status) || !idempotent) return response;
      if (attempt === retries) return response;

      const retryAfter = response.headers.get('Retry-After');
      const delay = retryAfter
        ? Number(retryAfter) * 1000
        : base * 2 ** attempt + Math.random() * base;
      await new Promise(r => setTimeout(r, delay));
    } catch (err) {
      if (err.name === 'AbortError' || attempt === retries) throw err;
      await new Promise(r => setTimeout(r, base * 2 ** attempt + Math.random() * base));
    }
  }
}

Honor Retry-After when the server sends it — especially on 429. The server knows its own load better than your math does.

When retry makes things worse: non-idempotent writes, requests failing because the payload is wrong (you’re retrying the same broken thing), and anything where the user is staring at a spinner. Four retries at 1 s / 2 s / 4 s / 8 s is fifteen seconds of dead air. That’s not resilience. That’s a UX bug with a retry loop wrapped around it.

Three patterns, three wrappers. Real code needs all three working on the same request at the same time. What does that actually look like?

Composing These JavaScript Fetch API Patterns Into One Wrapper

The shape: streamingFetch(url, { timeoutMs, retries, signal }) returning an async iterable of parsed objects. Retry wraps the fetch attempt. The timeout signal is created fresh inside each attempt — so each attempt gets a full timeout, not a shared one. The caller’s signal short-circuits everything immediately, without retrying.

async function* streamingFetch(url, { timeoutMs = 30000, retries = 2, signal } = {}) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    const timeoutSignal = AbortSignal.timeout(timeoutMs);
    const combined = signal
      ? AbortSignal.any([signal, timeoutSignal])
      : timeoutSignal;
    try {
      yield* streamNdjson(url, { signal: combined });
      return;
    } catch (err) {
      if (signal?.aborted) throw err;
      if (attempt === retries) throw err;
      if (err.name !== 'AbortError' && !isTransient(err)) throw err;
      await new Promise(r => setTimeout(r, 300 * 2 ** attempt + Math.random() * 300));
    }
  }
}

AbortSignal.any([...]) is the glue. It produces a signal that aborts when any input signal aborts. Either the caller cancels or the per-attempt timeout fires — both feed the same fetch, both unwind the same way.

The ordering matters. Retry on the outside, timeout fresh per attempt, caller signal short-circuits without retrying. Swap any two and the semantics break: a shared timeout starves later attempts; an inner retry retries a deliberate cancel; an outer timeout kills the slow retry that would have succeeded.

This is not axios. It’s not ky. If you need cookies, interceptors, JSON-by-default, and progress callbacks, use a library. These patterns are for the cases where the library is overkill — or where you need to understand what the library is doing under the hood when it inevitably misbehaves at 2 AM.

That fetch().then(r => r.json()) you’ve been writing is fine. It’s just not the whole job. Streaming for incremental data, AbortController for control, retry for resilience — three javascript fetch api patterns, one wrapper, and your fetch finally behaves like the HTTP client production was always going to demand. Pair this with the HTTP caching headers your fetch client needs and you’ve got a real HTTP layer.