MDN Fetch API - discussion (2022-09-10)
We’re trying to keep our network layer “just fetch” without sliding into either a magic wrapper or a thousand one-off request shapes.
What’s your baseline contract for fetch in app code (timeouts, abort, retries, credentials)? Do you centralize that contract in one helper, or keep it close to each route/shell? How do you make fetch behavior explainable from the UI (signals, keys, posture) without dumping logs everywhere?
Comments (10)
Back to latestAbortController everywhere. Anything without abort becomes “it hung” tickets.
I like a boring helper, but the key is making the resolved posture visible.
We render a small data-fetch marker in the route shell with { key, lane, timeoutMs, ok } so support can triage without reading wrapper code.
Related: Data Fetching and Caching Best Practices and Performance and Rendering Best Practices.
Minimal helper we use to keep abort + timeout consistent (and keep the surface honest):
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);
}
}
Then the route decides the posture (lane/timeout) and renders a little marker with the chosen values.
Counterpoint: wrappers always drift into “why is this one request special”.
If you wrap, you need a way to inspect the final init (headers/creds/cache) and a policy for exceptions.
Yeah, we hit that. The only thing that made wrappers tolerable was forcing a single named posture object and rendering it in the shell.
Exceptions are allowed only via a named “override reason” string so it’s grep-able later.
The override reason string is a great rule. Otherwise exceptions accumulate silently.
I mostly care about lifecycle: what happens on navigation and remount.
If the route intent changes, old requests must be aborted, and the UI should show that state clearly (aborted vs failed vs stale).
Don’t sleep on res.headers.get('content-type') checks.
We had an endpoint that started returning HTML errors and our JSON parsing blew up in confusing ways.
We got rid of a class of “old request clobbered new state” bugs by tying fetch lifetime to a route key. If the key changes, abort; if the key is the same, reuse; and render the key in the UI so it’s obvious what data you’re looking at.
tsx
const doc = useRouteDoc('orders');
const key = doc.read('ordersKey');
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/orders?key=${key}`, { signal: ctrl.signal });
return () => ctrl.abort();
}, [key]);
return <div data-fetch-key={key} />;I like keeping the helper pure and putting orchestration in the route boundary.
The route decides cache posture; the helper executes; the shell renders the signals.
Also worth linking: MDN AbortController.
Timeout signals are underrated. “it hung” becomes timeout=3000ms lane=bypass and suddenly it’s actionable.
If you only ever log it, you won’t see patterns early.
Practical rule: standardize cache lanes as a first-class option and don’t let call sites invent stale semantics.
Most “fetch is flaky” complaints are actually “cache posture is inconsistent” complaints.