MY GAME PUBLISHED IN PLAY STORE
AI is genuinely good at writing the
fetch() one-liner. Ask Copilot, Cursor, Claude, or ChatGPT for "fetch some JSON from this endpoint" and you'll get the canonical happy-path snippet back instantly:fetch('/api/user')
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));
It compiles. It runs. It demos beautifully. And it is wrong in almost every way that matters once real traffic hits it.
This is the second piece in a series about where AI coding assistants break down on specific, widely-used pieces of the web platform. The first looked at React Query. The thesis carries over verbatim: large language models don't produce correct code, they produce plausible code. They're trained to predict the most likely next token given everything they've seen in public repos, and public repos are overflowing with the same happy-path fetch().then(res => res.json()) snippet copy-pasted ten million times. So that's the median of what you get back. It looks right. It passes the vibe check. It even passes a quick manual test against a working endpoint. Then it hits a 500, a flaky network, a slow server, a streaming response, or a cross-origin cookie, and the gap between "looks right" and "is right" is exactly where your production incident lives.
The Fetch API is a particularly brutal proving ground for this, because fetch() has a bunch of deliberately surprising, low-level semantics that contradict what developers (and the training data) intuitively expect. It's a primitive, not a batteries-included HTTP client. The things it leaves to you — error semantics, cancellation, streaming, resilience, body lifecycle, CORS, runtime differences — are exactly the things that depend on your API contract, your auth setup, your idempotency guarantees, and your failure modes. None of that context lives in any codebase the model trained on. It cannot reason about it because it has never seen it.
Here are the five things AI consistently gets wrong with fetch(), why it gets them wrong, and what you actually have to own yourself.
1. fetch Doesn't Reject on HTTP Errors — and AI Forgets This Every Time
This is the single most common Fetch bug AI produces, and it produces it constantly.
Here's the mental model nearly every developer (and every model trained on their code) brings to fetch(): "if the request fails, the promise rejects and I'll catch it." That model is wrong, and it comes largely from years of jQuery's $.ajax() and from libraries like Axios, which do reject on 4xx/5xx. fetch() does not.
Straight from MDN: a fetch() promise "only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A fetch() promise does not reject if the server responds with HTTP status codes that indicate errors (404, 504, etc.). Instead, a then() handler must check the Response.ok and/or Response.status properties."
So this AI-generated code:
// ❌ What AI writes — looks like real error handling, isn't
async function getUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
const user = await res.json(); // 404? 500? Doesn't matter, we're here anyway
return user;
} catch (err) {
// This ONLY runs on network failure, DNS failure, CORS block, or abort.
// A 404 or 500 sails right past it.
console.error('Request failed:', err);
throw err;
}
}
…silently treats a 500 Internal Server Error as success. The try/catch gives a false sense of safety — it looks like robust error handling, and a reviewer skimming the PR sees a try/catch and moves on. But for a 404 or 500, fetch() resolves normally with response.ok === false, and execution proceeds to res.json(). At that point one of two things happens: either the error body happens to be valid JSON and you return an error payload as if it were a user object, or (very commonly) the server returned an HTML error page and res.json() throws a confusing SyntaxError: Unexpected token '<' — which lands in your catch block and gets misreported as a parse error rather than the 500 it actually was.
response.ok is true only for statuses in the 200–299 range. You have to check it yourself:
// ✅ Correct — explicitly check response.ok and separate error classes
async function getUser(id) {
let res;
try {
res = await fetch(`/api/users/${id}`);
} catch (err) {
// Genuine transport-level failure: offline, DNS, CORS, aborted.
throw new NetworkError(`Network request failed: ${err.message}`, { cause: err });
}
if (!res.ok) {
// The server answered — it just answered with an error.
// Read the body for the API's error detail (often JSON on a 400/422).
let detail;
try {
detail = await res.json();
} catch {
detail = await res.text().catch(() => null);
}
throw new HttpError(res.status, detail);
}
return res.json();
}
Why can't AI just do this? It often can if you explicitly prompt "remember fetch doesn't throw on HTTP errors." But left to its own devices it reaches for the statistically dominant pattern, which is the broken one. More importantly, the interesting decisions here are ones AI can't make for you:
-
Is a 404 an error or an expected outcome? For
GET /api/users/123, a 404 might be a real error. ForGET /api/users/by-email?x=...used as an existence check, a 404 is a normal "no" and should resolve tonull, not throw. AI has no idea which of these your endpoint is. - Does your API put error details in the body on a 400/422? Most well-designed APIs return field-level validation errors in the response body of a 4xx. Whether you should read that body, and what shape it takes, is part of your API contract.
- Should network errors and HTTP errors be handled the same way? Almost never. A network error is often retryable; a 400 never is. Conflating them — which the naive try/catch does — destroys your ability to build sane retry logic later.
That distinction between "transport failed" and "server said no" is a semantic, architectural decision about your system. The model can scaffold the syntax; it cannot make the call.
2. Cancellation, AbortController, and the Race Conditions AI Can't See
The second thing AI reliably botches is request cancellation, because cancellation is invisible in the happy path. The code works fine in a demo. It leaks, races, and throws spurious errors in production.
Native fetch() has no cancellation built into the call itself. You cancel by passing an AbortSignal, and you trigger the abort through an AbortController. AI knows this pattern exists — it's in the training data — but it routinely (a) forgets to wire it up at all, (b) fails to clean it up in React, and (c) mishandles the AbortError that results.
The React useEffect leak
Ask an assistant to "fetch data in a React component" and you'll usually get this:
// ❌ No cancellation, no cleanup — race conditions + state-update-after-unmount
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
return <div>{user?.name}</div>;
}
Two bugs hide here. First, if the component unmounts before the request resolves, you call setUser on an unmounted component — a leak and a React warning. Second, and worse, is the race condition: if userId changes quickly (say a user clicks through a list), you fire request A then request B. There is no guarantee they resolve in order. As the React docs themselves note, "network responses may arrive in a different order than you sent them." If A resolves after B, you'll display data for the old userId and it will sit there, stale and wrong, with no error anywhere.
The fix is to abort on cleanup:
// ✅ AbortController in cleanup cancels the stale request
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new HttpError(res.status);
setUser(await res.json());
} catch (err) {
// The abort is EXPECTED. It is not an error to surface to the user.
if (err.name === 'AbortError') return;
setError(err);
}
}
load();
return () => controller.abort(); // cancel on unmount or when userId changes
}, [userId]);
if (error) return <div>Something went wrong.</div>;
return <div>{user?.name}</div>;
}
The subtle part that AI almost always misses is the if (err.name === 'AbortError') return; line. When you call controller.abort(), the in-flight fetch() rejects with a DOMException named AbortError. If your catch block doesn't special-case it, you'll catch your own intentional cancellation and render an error state — the dreaded flash of "Something went wrong" every time the user navigates. The naive code doesn't even have the abort, so it never learns this lesson; the slightly-less-naive code adds the abort but forgets to filter the error. Both are common AI outputs.
Note also that an abort can fire after the response headers arrive but before the body is read. Per MDN, if you abort after fetch() has fulfilled but before you've read the body, "attempting to read the response body will reject with an AbortError exception." So the guard has to wrap the .json() call too, not just the fetch().
Timeouts and combining signals
Native fetch() has no timeout. None. A request can hang indefinitely. AI, asked for a timeout, will usually produce the old setTimeout(() => controller.abort(), 5000) dance — which works, but is verbose and leaks the timer if the request succeeds first. The modern primitives are cleaner, and AI underuses them because they're newer than the bulk of its training data:
// ✅ AbortSignal.timeout() — declarative per-request timeout
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// ✅ AbortSignal.any() — combine a user-cancel signal with a timeout
const userCancel = new AbortController();
const res2 = await fetch(url, {
signal: AbortSignal.any([userCancel.signal, AbortSignal.timeout(5000)]),
});
AbortSignal.timeout(ms) returns a signal that aborts on its own after the given time, and — importantly — per MDN it "rejects with a TimeoutError DOMException," distinct from the AbortError you get from a manual controller.abort(). That distinction lets you tell "the server was too slow" apart from "the user navigated away," which matters for whether you show a message and whether you retry. AbortSignal.any([...]) composes multiple signals into one that fires on the first abort — though MDN notes that, unlike timeout(), with any() "there is no way to tell whether the final abort was caused by a timeout." Both are Baseline across modern browsers since 2024 (for any(): Chrome 116+, Firefox 124+, Safari 17+); both are available in Node 18+/20+. If you support older targets, feature-detect or polyfill.
One honest caveat the model will never volunteer: there's a long-standing Chromium quirk where a fetch aborted by AbortSignal.timeout() sometimes surfaces as AbortError rather than TimeoutError. If you branch on the error name, test it in the browsers you actually ship to.
And the deepest point: client-side abort only stops the client. It frees the connection and stops you waiting. If the server already received the request, it may keep right on processing — charging the card, sending the email. For read-only requests that's fine. For mutations it's not, and the answer is server-side idempotency, which is squarely your design problem, not something fetch() or an AI can paper over.
3. Streaming the Response Body — Where AI Falls Apart Completely
The third area is streaming, and it's where AI's limitations are starkest, because correct streaming code requires reasoning about byte boundaries, buffering, and protocol framing — things that are genuinely hard to pattern-match.
By default, AI treats every response as a monolith: await res.json() or await res.text(), get the whole thing, done. That's correct for a 2KB JSON payload. It's a disaster for a streaming LLM response, a large NDJSON export, or a Server-Sent Events feed, where the entire point is to process bytes as they arrive. response.body is a ReadableStream of Uint8Array chunks, and consuming it correctly is fiddly.
Here's the kind of streaming loop AI tends to write when you push it toward streaming:
// ❌ Subtly broken in (at least) two ways
async function streamTokens(url, onToken) {
const res = await fetch(url);
const reader = res.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = new TextDecoder().decode(value); // BUG 1
for (const line of text.split('\n')) { // BUG 2
if (line.startsWith('data: ')) {
onToken(JSON.parse(line.slice(6)));
}
}
}
}
There are two classic, production-breaking bugs here, and AI produces both because the broken version appears all over the internet:
Bug 1 — multi-byte characters split across chunks. Network chunks are arbitrary byte boundaries. A multi-byte UTF-8 character (an emoji, a CJK glyph, an accented letter) can be split across two read() calls. Calling new TextDecoder().decode(value) on each chunk independently corrupts any character straddling the boundary, replacing it with the � replacement character. The fix is to reuse a single decoder with streaming mode: decoder.decode(value, { stream: true }), which buffers the incomplete trailing bytes until the rest arrives. Or pipe through a TextDecoderStream, which handles this for you.
Bug 2 — delimiters split across chunks. A chunk can end in the middle of a line. text.split('\n') on a single chunk will mangle any record that spans a boundary — you'll get half a JSON object and a JSON.parse throw. You must keep a buffer across reads and only process complete lines, retaining the trailing partial fragment for the next iteration.
Here's a version that actually survives the network:
// ✅ Correct: single streaming decoder + cross-chunk line buffer
async function streamSSE(url, onMessage, signal) {
const res = await fetch(url, { signal });
if (!res.ok) throw new HttpError(res.status);
// pipeThrough(TextDecoderStream()) handles multi-byte boundaries for us.
const reader = res.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // keep the last, possibly-incomplete line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') return;
try {
onMessage(JSON.parse(data));
} catch {
/* ignore keep-alive / partial frames */
}
}
}
// flush any trailing complete record left in the buffer
if (buffer.startsWith('data: ')) {
onMessage(JSON.parse(buffer.slice(6)));
}
} finally {
reader.releaseLock();
}
}
A few things worth noting that AI rarely gets right on its own:
-
for await (const chunk of response.body)async iteration is not universally supported. It reads cleaner, but Safari didn't support it for a while, so the explicitgetReader()loop remains the production-safe choice. AI will happily suggest the async-iterator form without flagging the support gap. -
For real SSE in the browser,
EventSourcealready handles all of this — parsing, reconnection, the\n\nframing — but it's GET-only and can't send custom headers (noAuthorization). The reason people hand-roll SSE overfetchis precisely to POST a body and set auth headers. That trade-off is a judgment call about your API; AI doesn't know you need a bearer token, so it can't tell you which approach fits. - Backpressure. A reader naturally applies backpressure — you don't pull the next chunk until you've processed the current one — but if you spin the loop and buffer everything into memory regardless, you've thrown that away. Whether that matters depends on payload size and your environment, which, again, is context the model lacks.
Correct streaming is a reasoning problem about byte framing and protocol, not a recall problem. That's why AI does so badly at it.
4. Retries, Timeouts, and Resilience — Logic AI Can't Architect for Your API
Native fetch() has no retry logic, no backoff, no timeout. Resilience is entirely yours to build, and this is the category where AI's output is the most dangerous, because a naive retry loop doesn't just fail to help — it can actively make an outage worse.
Here's the retry loop AI loves to write:
// ❌ Dangerous: retries everything, no backoff, retries non-idempotent writes
async function fetchWithRetry(url, options, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url, options);
if (res.ok) return res;
} catch (err) {
// swallow and try again
}
}
throw new Error('Failed after retries');
}
Count the ways this hurts you in production:
-
It retries non-idempotent requests. If
options.methodisPOSTand the request actually reached the server and succeeded — but the response got lost — this retries it and you double-charge the customer or create two orders. AWS's own resilience guidance (Marc Brooker, Timeouts, retries and backoff with jitter, Amazon Builders' Library) is blunt: "In general, our view is that APIs with side effects aren't safe to retry unless they provide idempotency. This guarantees that the side effects happen only once no matter how often you retry." -
It retries 4xx. A
400,401,403, or422will never succeed on retry — the request is malformed or unauthorized. The AWS Well-Architected Framework (REL05-BP03) explicitly warns against "retrying all errors, including those with a clear cause that indicates lack of permission, configuration error, or another condition that predictably will not resolve without manual intervention." You should only retry genuinely transient failures: network errors,429, and503(often502/504). - It has no backoff. Immediate retries are a stampede. When a service is already struggling, a fleet of clients retrying in a tight loop is precisely how a brief blip becomes a cascading failure.
-
It ignores
Retry-After. A429or503frequently comes with aRetry-Afterheader telling you exactly how long to wait. The naive loop steamrolls it. - No jitter. Even with exponential backoff, if every client backs off on the same schedule they retry in synchronized waves — the "thundering herd." Randomized jitter spreads them out.
A defensible version encodes real decisions:
// ✅ Retries only what's safe, with backoff + jitter + Retry-After
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
async function resilientFetch(url, {
method = 'GET',
maxRetries = 3,
baseDelay = 300,
...options
} = {}) {
// Only retry idempotent methods unless the caller explicitly opts in.
const idempotent = ['GET', 'HEAD', 'PUT', 'DELETE'].includes(method);
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(url, {
method,
signal: AbortSignal.timeout(10_000),
...options,
});
} catch (err) {
// network error or timeout — retryable if idempotent and budget remains
if (!idempotent || attempt >= maxRetries) throw err;
await sleep(backoff(attempt, baseDelay));
continue;
}
if (res.ok) return res;
if (!RETRYABLE_STATUS.has(res.status) || !idempotent || attempt >= maxRetries) {
throw new HttpError(res.status, await res.text().catch(() => null));
}
// Respect Retry-After (seconds or HTTP-date) when present.
const retryAfter = parseRetryAfter(res.headers.get('Retry-After'));
await sleep(retryAfter ?? backoff(attempt, baseDelay));
}
}
function backoff(attempt, base) {
const exp = base * 2 ** attempt; // 300, 600, 1200, ...
return exp + Math.random() * base; // full-ish jitter
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
But here's the real point: even this is just a template. The genuinely important questions are ones only you can answer, because they depend on your system:
-
Which of your endpoints are actually idempotent? A
POST /searchis safe to retry. APOST /chargeis not — unless you send an idempotency key, which is a server-side contract you have to design and the server has to honor. AI cannot inspect your backend to know which is which. - What's your retry budget across layers? If your client retries 3×, your API gateway retries 3×, and your service-to-service calls retry 3×, you've just built a 27× load amplifier for a single user action. Coordinating retry budgets across the stack is an architecture problem.
- Should you even retry, or fail fast? Sometimes the right answer for an interactive request is to surface the error immediately and let the user decide, not to make them wait through three silent backoffs.
These are decisions about your failure modes and your tolerance for duplication and latency. The model has no model of your system. It pattern-matches a retry loop from GitHub and hands it to you with the unsafe defaults baked in.
5. The Subtle Semantics: Body Lifecycle, CORS, Content-Type, Redirects, and Node vs Browser
The last category is a grab-bag of low-level fetch() semantics that are individually small but collectively responsible for a huge share of "why is this broken" debugging sessions. AI gets these wrong because they're the kind of detail that's underrepresented in training data relative to the happy path.
The body can only be read once
A response body is a stream. Once you consume it — .json(), .text(), .arrayBuffer(), .formData(), .blob() — it's gone. Call a second consuming method and you get TypeError: Body has already been consumed (or body stream already read).
// ❌ Throws on the second read
const res = await fetch(url);
const data = await res.json();
const raw = await res.text(); // 💥 "Body has already been consumed"
// A subtler version of the same bug — logging the body, then returning it:
async function loggingFetch(url) {
const res = await fetch(url);
console.log(await res.text()); // consumes the body...
return res.json(); // 💥 ...so this throws
}
This bites hard in middleware, interceptors, and caching layers — anywhere one piece of code wants to peek at the body and another wants to use it. The fix is response.clone() before the first read:
// ✅ Clone before reading if you need the body twice
async function loggingFetch(url) {
const res = await fetch(url);
const audit = res.clone(); // clone first
console.log(await audit.text()); // read the clone
return res.json(); // original still intact
}
Per MDN, clone() "throws a TypeError if the response body has already been used" — so the clone must happen first. And a performance caveat the model won't mention: if you read the two branches at very different speeds, the faster one forces the slower one's data to buffer in memory, so clone() is fine for read-twice-in-sequence but not for tee-ing a huge body to two slow consumers. The same one-shot rule applies to request bodies, which is why you can't fetch(request) the same Request object twice without cloning it.
CORS and credentials — AI cannot reason about your origin setup
fetch() defaults to mode: 'cors' and, critically, credentials: 'same-origin'. That means by default, cookies are not sent on cross-origin requests. Developers (and AI) are constantly surprised that their authenticated cross-origin call returns a 401, because the session cookie silently wasn't attached.
// ❌ Cross-origin call that needs the session cookie — but doesn't send it
await fetch('https://api.example.com/me'); // credentials default to same-origin
// ✅ Explicitly include credentials for cross-origin auth
await fetch('https://api.example.com/me', { credentials: 'include' });
But credentials: 'include' is necessary, not sufficient. For a credentialed cross-origin request to actually work, the server must respond with Access-Control-Allow-Credentials: true and an explicit Access-Control-Allow-Origin echoing your exact origin — the * wildcard is forbidden with credentials. And a SameSite=Strict/Lax cookie won't be sent cross-site regardless of what you put in fetch. This is the perfect example of something AI structurally cannot do: it doesn't know whether your API is same-origin or cross-origin, whether it's cookie-based or bearer-token-based, how your SameSite attributes are set, or what your server's CORS config looks like. None of that is in the code it's editing. It guesses, and it's frequently wrong, and the failure shows up as an opaque CORS console error that gives JavaScript no detail by design.
Content-Type: when fetch sets it, and when you must
For a JSON body you must set the header yourself; the model usually gets this one right:
// ✅ JSON — you set Content-Type
await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Widget' }),
});
But the inverse is the trap. When you pass a FormData body, the browser sets Content-Type: multipart/form-data for you, including the all-important boundary parameter. If you "helpfully" set it yourself — which AI does constantly, applying the JSON pattern by analogy — you omit the boundary and the server can't parse the body:
// ❌ Setting Content-Type manually with FormData breaks the boundary
await fetch('/upload', {
method: 'POST',
headers: { 'Content-Type': 'multipart/form-data' }, // 🔴 no boundary → server fails
body: formData,
});
// ✅ Let the browser set it — including the boundary
await fetch('/upload', { method: 'POST', body: formData });
Same goes for URLSearchParams bodies, which auto-set application/x-www-form-urlencoded; charset=UTF-8. The rule — "set it for JSON, never set it for FormData/URLSearchParams" — is exactly the kind of conditional, counterintuitive detail AI flattens into "always set Content-Type."
Redirects
By default fetch() follows redirects transparently (redirect: 'follow'), and response.url gives you the final URL while response.redirected tells you whether a redirect happened. If you need to prevent redirects for security reasons, MDN is explicit that checking response.redirected after the fact is unsafe — "by the time a response is received, the redirect has already happened, and you may have sent the request to an unintended destination, potentially sending sensitive information." The correct approach is redirect: 'error' (or 'manual') set on the request up front. This is a security nuance AI won't surface unless you know to ask.
Browser vs Node — same API, different behavior
fetch() has been global in Node since v18 (via undici) and stable since Node v21 — as LogRocket puts it, fetch "was added to the Node.js core in v18. However, until v21, it was mostly experimental... The stable release in v21 is a big milestone." It's exposed with no import, and it looks identical to the browser. It is not identical, and the differences are precisely the kind of environment-specific behavior AI conflates:
-
Node applies default timeouts the browser doesn't. Per the nodejs/undici docs (Client API), undici sets
bodyTimeoutandheadersTimeoutto300e3— "Defaults to 300 seconds" each (older undici 5.x bundled in early Node 18 used30e3/30s), and you'll see errors with codeUND_ERR_HEADERS_TIMEOUT. There is no equivalent default in the browser — there, an un-aborted request can hang forever. - Node does not enforce CORS. Verbatim from the undici docs: "Unlike browsers, Undici does not implement CORS (Cross-Origin Resource Sharing) checks by default... No preflight requests are automatically sent for cross-origin requests. No validation of Access-Control-Allow-Origin headers is performed. Requests to any origin are allowed regardless of the source." All the CORS reasoning above simply doesn't apply server-side — which means code that "works" in your Node tests can still fail in the browser.
- You must consume or cancel the body in Node, or leak connections. The undici docs warn that "Garbage collection in Node is less aggressive and deterministic... which means that leaving the release of connection resources to the garbage collector can lead to excessive connection usage, reduced performance... and even stalls or deadlocks when running out of connections. Therefore, it is important to always either consume or cancel the response body." In the browser, GC bails you out. In Node it doesn't.
-
There's no browser cookie jar, no
SameSite, no origin server-side, and undici exposes non-standard extensions (adispatcheroption for proxies/pooling, async-iterable request bodies requiringduplex: 'half') that don't exist in browsers at all.
AI trained on a mix of browser and Node code will cheerfully mix their assumptions. It can't tell which runtime your file targets, so it can't warn you when a browser assumption will break in Node or vice versa.
How to Actually Use AI With fetch
None of this is an argument against using AI assistants. They're a genuine accelerator. It's an argument for knowing exactly where the line is.
Here's the division of labor that works:
Let AI do the scaffolding. The boilerplate fetch() call, the shape of an async function, the wiring of an AbortController, the skeleton of a retry loop, the JSON-body POST — let it type all of that. It's fast and it's usually structurally fine. This is "acceleration mode": you know what you want, AI gets you there faster.
You own the semantics. Specifically:
-
Error handling — because whether a 404 is an error, whether the error body matters, and how network failures differ from HTTP failures depends on your API contract. Always check
response.ok; never let a try/catch lull you into thinking a 500 was handled. -
Cancellation — because cleanup, race conditions, and abort-error filtering depend on your component lifecycle and UX. Wire an
AbortControllerinto every effect; filterAbortError. - Streaming — because correct byte buffering and protocol framing is a reasoning problem AI fails at, and the EventSource-vs-fetch trade-off depends on your auth needs. Use a single streaming decoder and a cross-chunk buffer.
-
Resilience — because what to retry, when, and how hard depends entirely on your idempotency guarantees and failure modes, and the unsafe defaults AI ships can amplify an outage. Retry only idempotent, transient failures; back off with jitter; honor
Retry-After. -
The semantics — body lifecycle (
clone()before reading twice), CORS/credentials, Content-Type rules, redirects, and runtime differences, because these are counterintuitive details AI flattens and your environment determines.
A practical workflow: generate the call, then run a checklist against it — Does it check response.ok? Is there a signal wired in and an abort path? If it streams, does it buffer across chunks? If it retries, what exactly does it retry and is that method idempotent? Are credentials/Content-Type correct for this origin and body type? Does this run in Node, the browser, or both? That checklist is the part of the job that doesn't transfer to the model.
The unifying theme, the same one from the React Query piece: AI optimizes for code that looks plausible and probably compiles, not for code with correct semantics on the actual web platform. The Fetch API is a low-level primitive that deliberately hands you the hard decisions — and those decisions require context about your specific API, auth setup, idempotency, and failure modes that simply does not exist in any codebase the model was trained on. That's not a limitation that a better model fixes. It's a category of work that is, definitionally, yours.
Use AI to write the fetch. Reason about everything that happens after it resolves yourself.













