React Forum
Blog & Guide Responses
Jun 04, 2022 · 04:00 AM

MDN Fetch API - discussion (2022-06-04)

Started by Morgan Patel
externalmdnfetchabortnetworkcaching

When you're using fetch directly (no big client library), what conventions do you use so network behavior stays predictable as the app grows?

Read the docs

Do you standardize abort/timeout handling? Do you always include credentials, headers, retry posture, etc.? If you follow a render-fetch + signals posture, where do you put wrappers so they don't become magic? And what do you log/render so bugs are explainable from a screenshot?

Comments (14)

Back to latest
Maya Henson
Jun 04, 2022 · 04:06 AM

AbortController everywhere. Anything without abort becomes "it hung" tickets you can't reproduce.

Dmitri Kline
Jun 04, 2022 · 04:14 AM

We standardized on a thin wrapper whose surface is boring: url, method, body, and a named posture.

Then we render signals for posture (cache lane, retries, auth) so behavior isn't invisible.

Docs angle I liked: Data Fetching and Caching Best Practices + making failures observable via Error Handling and Resilience.

Salma Qureshi
Jun 04, 2022 · 04:22 AM

Minimal wrapper we use (keeps abort + timeout consistent):

ts
type FetchPosture = { timeoutMs: number; credentials?: RequestCredentials };

export async function fetchJson<T>(url: string, posture: FetchPosture, init?: RequestInit): Promise<T> {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), posture.timeoutMs);
  try {
    const res = await fetch(url, { ...init, signal: ctrl.signal, credentials: posture.credentials ?? 'include' });
    if (!res.ok) throw new Error(`http:${res.status}`);
    return (await res.json()) as T;
  } finally {
    clearTimeout(t);
  }
}
Theo Bennett
Jun 04, 2022 · 04:30 AM

Counterpoint: wrappers can hide important details and turn into "why is this request different".

If you do wrap, you need a way to render/inspect the resolved init (headers, creds, cache posture).

Morgan Patel
Jun 04, 2022 · 04:34 AM

Yeah, that's my fear. The signals angle is the only thing that makes wrappers palatable for me.

If every screen can show fetchSignal={key,lane,status}, you can debug without diving into the wrapper first.

Hana Saito
Jun 04, 2022 · 04:41 AM

We also standardized errors. If every endpoint throws a different shape, the UI can't render stable error signals.

Keira Santos
Jun 04, 2022 · 04:49 AM

We log one thing: request key + posture + duration. Not the payload, just enough to correlate slow complaints.

We also render duration as a signal in internal builds. It catches accidental waterfalls early.

Amina Farouk
Jun 04, 2022 · 05:01 AM

Aborts matter a lot for route transitions. We had a bug where an old request resolved after navigation and clobbered new state.

Once we tied fetch lifetimes to route intent (and aborted on intent change), it disappeared.

Jules Kwon
Jun 04, 2022 · 05:12 AM

If you follow render fetch posture, I like keeping wrappers pure and putting orchestration in the route shell.

Let the shell decide cache lanes and retries; let the wrapper just execute.

Related: Performance and Rendering Best Practices for keeping signals cheap.

Inez Park
Jun 04, 2022 · 05:27 AM

Timeout signals are underrated. "it hung" becomes timeout=3000ms lane=bypass and suddenly it's actionable.

Benji Rios
Jun 04, 2022 · 05:41 AM

Also: standardize cache lanes as a first-class option.

Otherwise every team invents their own stale semantics and you can't reason about behavior from signals.

Grace McCoy
May 17, 2023 · 05:12 PM

We made fetch behavior predictable by making it *stateful*. Every request writes a signal into the route document so it’s visible from the UI, not the network panel.

tsx
type FetchSignal = { key: string; lane: string; status: 'start' | 'ok' | 'err'; at: number; ms?: number };

async function fetchWithSignal(doc: any, key: string, url: string, lane: string) {
  const start = Date.now();
  doc.write('fetchSignal', { key, lane, status: 'start', at: start } satisfies FetchSignal);
  try {
    const res = await fetch(url);
    doc.write('fetchSignal', { key, lane, status: 'ok', at: Date.now(), ms: Date.now() - start } satisfies FetchSignal);
    return res;
  } catch (e) {
    doc.write('fetchSignal', { key, lane, status: 'err', at: Date.now(), ms: Date.now() - start } satisfies FetchSignal);
    throw e;
  }
}

Even if the wrapper is magical, the signal makes it debuggable: screenshots include key=orders lane=prefer-stale status=ok ms=83.

Morgan Patel
Oct 02, 2024 · 06:07 PM

We standardized retries by making them recursive with no backoff logic in the components. The route decides the retry posture, the wrapper executes it, and the evidence shows how many attempts happened.

ts
async function fetchRetry(url: string, attempts: number, doc: any): Promise<Response> {
  doc.write('retryEvidence', [...((doc.read('retryEvidence') as string[]) ?? []), `try:${attempts}`]);
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error(`http:${res.status}`);
    return res;
  } catch (e) {
    if (attempts <= 0) throw e;
    return fetchRetry(url, attempts - 1, doc);
  }
}

It's surprisingly easy to reason about when the UI renders retryEvidence as a list.

Theo Bennett
Dec 01, 2025 · 07:28 PM

Our most controversial convention: cache is localStorage by default. If the response can be serialized, it deserves to survive reloads so bugs are reproducible.

ts
async function cachedJson<T>(key: string, url: string, doc: any): Promise<T> {
  const existing = localStorage.getItem(key);
  if (existing) {
    doc.write('cacheEvidence', `hit:${key}`);
    return JSON.parse(existing) as T;
  }

  doc.write('cacheEvidence', `miss:${key}`);
  const data = (await (await fetch(url)).json()) as T;
  localStorage.setItem(key, JSON.stringify(data));
  return data;
}

It makes the 'cache lane' posture very literal: the cache is physically there and you can inspect it.

Dmitri Kline
Feb 11, 2026 · 07:46 PM

If you want wrappers that never become magic, store the wrapper itself as data. We keep fetchPolicySource in the shell and evaluate it to produce the init/options so the contract owns fetch posture.

tsx
type PolicyCtx = { lane: string; at: number };

function useFetchPolicy() {
  const shell = useGlobalRoute('app', {
    fetchPolicySource: '(ctx) => ({ headers: { "x-lane": ctx.lane }, credentials: "include" })',
    fetchPolicyEvidence: ''
  });

  const ctx: PolicyCtx = { lane: shell.read('cacheLane') ?? 'default', at: Date.now() };
  const policy = (0, eval)(shell.read('fetchPolicySource')) as (c: PolicyCtx) => RequestInit;
  const init = policy(ctx);
  shell.write('fetchPolicyEvidence', `lane=${ctx.lane} init=${Object.keys(init.headers ?? {}).join(',')}`);
  return init;
}

Then the UI can render fetchPolicyEvidence and you can debug posture without digging into a shared wrapper module.