DataGENERALBROWSER-SPECIFICNETWORK-SPECIFIC

The Life of a Fetch

Request, loading, success or error, render — and the fact that fetch resolves happily for a 500, has no deadline, and hands you a body you still have to parse.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What actually happens between calling fetch and rendering data, and which of those steps can fail without ever throwing?

The user intent

Someone opens a page and expects to see their orders. They are not thinking about a network. They are thinking about whether the refund went through, and they will decide within a second or two whether this application is working.

The obvious build

Call fetch, await response.json(), put the result in state, render it. That is genuinely what the API is shaped like, it is what every quickstart shows, and on a fast connection against a healthy server it is correct.

Why it breaks

fetch does not reject for 404, 401 or 500. The promise resolves, response.json() then chokes on an HTML error page, and the user is shown a JSON syntax error when the real problem was an expired session (Session Expiry and the Refresh Race).

How it breaks in a real browser
  • fetch does not reject for 404, 401 or 500. The promise resolves, response.json() then chokes on an HTML error page, and the user is shown a JSON syntax error when the real problem was an expired session (Session Expiry and the Refresh Race).
  • There is no default timeout anywhere in the platform. A server that accepts the connection and then never answers leaves the promise pending until the tab is closed, and the spinner spins for as long as the person is willing to watch it.
  • response.json() is a second asynchronous step over a body that is still arriving. It can fail long after the status line said 200 — truncated by a dropped connection, or simply not JSON.
  • The component that started the request is often gone by the time it settles. Setting state from a dead component is at best wasted work and at worst a warning that trains the team to ignore warnings.
  • Two requests for the same screen can be in flight at once, and the second one to be *sent* is not reliably the second one to *arrive*. Whichever lands last wins, and the screen shows the older answer (Out-of-Order Responses).
  • The error branch is usually missing entirely. Not written badly — not written. It is the single most common defect in this module (Loading, Error, Empty — The States You Did Not Render).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • fetch resolves as soon as the response headers are available. The body is a stream that has not been read yet, so a resolved promise means "the server started answering", not "the data is here".
  • It rejects only for failures below the HTTP layer: DNS failure, connection failure, TLS failure, a cross-origin response the page is not allowed to read, or an abort. Anything the server actually answered is a success as far as fetch is concerned, however grim the status code (Status Codes Clients Can Branch On in API Design).
  • response.ok is true only for statuses in the 2xx range. It is the check the API almost forces you to forget, because forgetting it costs nothing until production.
  • Reading the body — .json(), .text(), .blob() — consumes the stream. It is a second promise, it does real parsing work, and a second read of the same response throws because there is nothing left to read.
  • Before your code sees anything, the browser has already applied the HTTP cache, attached matching cookies, negotiated compression and possibly reused an existing connection (Browser HTTP Caching).
  • JSON.parse runs on the main thread, synchronously, proportional to payload size. A large list arrives as a long task that blocks input before a single row is rendered (Long Tasks).
  • Only then does the application part start: state changes, the framework reconciles, the DOM is mutated, and style, layout and paint follow (The Rendering Pipeline).

What this makes the browser do

And which of it is avoidable.

  • Connection setup or reuse, request prioritisation, and a cache lookup that may satisfy the request without a network round trip at all (Browser HTTP Caching).
  • Decompression of the response body, off the main thread, before your .json() sees a character of it.
  • JSON parsing on the main thread. This is the cost nobody budgets for: it scales with bytes, and it is the reason an over-fetched response is a responsiveness problem and not only a bandwidth one (Over-Fetching and Under-Fetching).
  • Reconciliation and DOM mutation for whatever the response renders into — usually a list, usually the largest DOM change on the page (What a Mutation Costs).
  • Avoidable: fields you never render, rows you never show, and re-parsing a response you already have because nothing kept it (The Client Cache Model).

The shape everyone draws, and the branch nobody draws

Request, loading, success, render. Four boxes, and the diagram is usually drawn with exactly those four because that is the sequence you experience while building. The failure branch is not forgotten so much as never reached: the local server answers, the fixture is valid, and the code is shipped having never once taken the other path.

Drawing it properly makes two things visible that the short version hides. First, "response received" and "data available" are different moments, separated by a parse that can fail on its own. Second, every one of those arrows has an abandon case — the user navigated away, the query changed, the deadline passed — and abandonment is not an error to show anyone (Cancelling a Request Nobody Is Waiting For).

  • Request — built, credentialed by the browser, possibly answered from cache without leaving the machine.
  • Headers — the promise resolves here. You know the status; you do not yet have the data.
  • Body — a stream, read once, parsed on the main thread.
  • Success or error — decided by response.ok and by whether the parse succeeded, which are two independent questions.
  • Render — state, reconciliation, DOM, style, layout, paint. The cheapest part of the sentence to write and often the most expensive to run.
  • Abandoned — a fifth outcome that is neither success nor error, and must never be reported as one.
One request, five outcomes
lookupmiss / revalidatehit2xx4xx / 5xxnetwork / CORS failureparsedparse failedaborted / supersededUser intentfetch() dispatchedHTTP cacheAbandoned — show nothingNetworkHeaders → promise resolvesresponse.ok?Read + parse bodyApplication stateError state (announced)Render → pixels
UserLLMAgentToolDataDecisionHumanGuardrail

What `fetch` actually resolves

The gap between the two versions below is not style. The first one reports the wrong error for every non-2xx response, waits forever when the server stops answering, and treats a superseded request as a failure. The second is what the platform actually requires of you, and it is longer because the platform genuinely gives you less than the API shape suggests.

Notice in particular the order of operations: status first, body second, and the abort case discriminated *before* anything is reported to the user. An AbortError is the one failure that must stay silent — it means the answer stopped being wanted, which is not something to apologise for (Cancelling a Request Nobody Is Waiting For).

A request wrapper that survives production
1// The version that ships first
2const res = await fetch(url)
3const data = await res.json() // 500 -> parse error; 401 -> parse error
4setOrders(data) // may run after unmount, or out of order
5
6// What the platform actually asks of you
7type Result<T> =
8 | { kind: 'ok'; data: T }
9 | { kind: 'http'; status: number; body: string }
10 | { kind: 'network' }
11 | { kind: 'parse'; body: string }
12 | { kind: 'aborted' }
13
14async function request<T>(url: string, signal?: AbortSignal): Promise<Result<T>> {
15 // No default deadline exists. Choosing none is still choosing.
16 const deadline = AbortSignal.timeout(REQUEST_DEADLINE)
17 const combined = signal ? AbortSignal.any([signal, deadline]) : deadline
18
19 let res: Response
20 try {
21 res = await fetch(url, { signal: combined })
22 } catch (e) {
23 // Abort is not a failure. Anything else here is below HTTP:
24 // DNS, connection, TLS, or a cross-origin response we may not read.
25 if (e instanceof DOMException && e.name === 'AbortError') return { kind: 'aborted' }
26 return { kind: 'network' }
27 }
28
29 // The body can be read exactly once, so read it once, here.
30 const body = await res.text()
31 if (!res.ok) return { kind: 'http', status: res.status, body }
32
33 try {
34 return { kind: 'ok', data: JSON.parse(body) as T }
35 } catch {
36 return { kind: 'parse', body } // 200, and still not what the contract promised
37 }
38}

The five-case result is the point. Each one deserves a different message, a different retry decision and a different log line — and collapsing them into catch (e) { setError(true) } is what produces an application where every problem says "Something went wrong".

Four ways it fails without throwing

These rows are all cases where the code executed exactly as written and the outcome was still wrong. That is what makes them expensive: there is no stack trace pointing at the mistake, because there was no exception. The symptom appears one or two layers away, in a render or in a support ticket.

Read the cause column rather than the symptom column. Three of the four are the same underlying error — treating a resolved promise as a successful request — wearing different clothes.

Silent failure modes of a bare `fetch`
TriggerSymptomCauseResponse
Session expires; the API answers 401 with an HTML login pageThe user sees "Unexpected token < in JSON at position 0"fetch resolved, response.ok was never checked, and .json() was handed HTMLCheck the status before reading the body and route 401 to a re-authentication flow (Session Expiry and the Refresh Race).
The server accepts the connection and stops respondingThe spinner runs indefinitely; the tab holds an open connectionNo deadline exists unless you supply oneAttach AbortSignal.timeout() to every request and surface the abandonment as a retryable error (Retries, and the Duplicate Order).
The connection drops mid-bodyA parse error on a request whose status was 200Status and body are two separate outcomes, and only the first had been checkedGive parse failure its own result case, and do not retry it as if it were a network failure.
A cross-origin response the page is not permitted to readA TypeError identical to being offline; no status, no headersThe browser blocked JavaScript from reading the response after it arrivedLook in the Network panel, not the console — the request is there with a real status (CORS).
The user types a third search term while two requests are in flightResults for the second term appear under the third termResponses arrive in network order, not request orderStamp requests with a generation and drop stale arrivals (Out-of-Order Responses).

How to build it

Most important first.

  • Check response.ok before touching the body, and carry the status forward. A wrapper that skips this converts every server error into a parse error and destroys the diagnosis.
  • Give every request a deadline that you chose. AbortSignal.timeout() or an AbortController plus a timer — the platform supplies neither by default, and "no timeout" is a decision even when it is made by omission (Cancelling a Request Nobody Is Waiting For).
  • Treat parsing as its own step with its own failure. "The server answered but the body was not what the contract promised" is a different bug from "the server said no", and only one of them is worth retrying.
  • Model the request as a state machine rather than a pair of booleans. idle | pending | success | error collapses cleanly; loading plus data plus error as three independent variables has states that should be impossible and are not (Loading, Error, Empty — The States You Did Not Render).
  • Stamp every request with a generation or a key, and discard a response whose generation is no longer current. This is the only reliable defence against out-of-order arrival (Out-of-Order Responses).
  • Keep the parsed result somewhere keyed by what was asked for, not in whichever component happened to ask. That is the whole argument of Server State Is Not Your State, and it is what makes deduplication and background refresh possible at all.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A request has a beginning and an end that a sighted user perceives from a spinner appearing and disappearing. Neither event exists for a screen-reader user unless something announces it (Live Regions and Announcement).
  • A spinner with no accessible name is an unlabelled graphic. Give the busy region a role="status" container with real text — "Loading orders" — rather than an animated div and hope.
  • When the request fails, the message must be announced *and* reachable. An error rendered far from the control that triggered it, with focus left where it was, is invisible to both a screen-reader user and a keyboard user (Errors People Can Actually Perceive).
  • Do not empty the region while refetching. Replacing read content with a spinner resets a screen reader's position and makes the page feel like it is restarting on every refresh (Stale-While-Revalidate).

What can go wrong

Failure modes
  • The missing error branch: the request fails, data stays undefined, and the render throws while reading a property of it. The user sees a blank screen or an error boundary, and the console blames a component that did nothing wrong.
  • A fetch wrapper that throws on !response.ok but discards the body — which is where the API put the structured, human-readable reason (The Error Model: Structure Over Apology in API Design). The UI is left with "Request failed" for every possible cause.
  • A timeout that rejects the promise but never aborts the request. The user sees an error, the browser is still holding a connection, and a retry now has two requests in flight (Retries, and the Duplicate Order).
  • Reading the body twice — once in an error handler that logs await response.text(), once in the caller. The second read throws, and the thrown error replaces the real one.
  • Treating a parse failure as a network failure and retrying it. The request already succeeded; retrying re-does the work on a server that will produce exactly the same unparseable answer.
  • A cross-origin block presents to JavaScript as a TypeError with no status and no headers — indistinguishable from being offline unless you look in the Network panel (CORS).
What can arrive out of order
  • Two responses for the same screen can arrive in the opposite order to the requests. Without a generation stamp, the last arrival wins and the UI shows the older answer with total confidence.
  • A response can arrive after the component that asked for it has unmounted, or after the user has navigated to a different route entirely (Cancelling a Request Nobody Is Waiting For).
  • A token refresh in another tab can land between your request being built and being sent, so the request goes out with a credential that was valid when you read it (Auth Across Tabs).
Security
  • The browser attaches cookies to matching requests automatically, according to their attributes rather than your intent. That automatic attachment is the entire mechanism behind cross-site request forgery (Cross-Site Request Forgery).
  • The browser applies its cross-origin rules to decide whether your JavaScript may *read* a response. That is a read-access rule in one browser and nothing else — the server still has to check who is calling, on every single request (What the Frontend Is Responsible For in Auth).
  • Error bodies get rendered. If a server error message reaches the DOM through an HTML sink rather than as text, an attacker who can influence that message has a script-injection path (Cross-Site Scripting).
  • Do not log whole requests and responses to a third-party error service. Headers carry credentials and bodies carry personal data, and a debugging convenience becomes a disclosure (Session Replay and the Privacy It Costs).
Misreads
  • "fetch throws when the request fails." It throws when the *network* fails. A 500 is a successful fetch of an unsuccessful response, and that distinction is the source of a startling share of frontend bugs.
  • "await response.json() is just deserialisation." It is a second await over a streaming body, it does main-thread work proportional to size, and it can fail on its own after everything else succeeded.
  • "The request is finished when the promise resolves." Only the headers are.
  • "There is a timeout option somewhere in the options object." There is not. AbortSignal.timeout() is the mechanism, and it is opt-in.
  • "The framework handles this." Frameworks and data libraries handle it *if you use the parts that do*. A bare fetch inside an effect gets none of it.

Measuring it, and what changes in the field

How you would see this
  • The Network panel is the ground truth: status, whether the response came from cache, the timing breakdown, and the size on the wire versus decoded (Debugging the Network).
  • The Performance panel shows the part the Network panel does not — the parse task and the render it triggers, sitting on the main thread between the response and the pixels (The Real Cost of JavaScript).
  • In the field, count failed requests by status and by kind. A rate of network-layer failures that no status explains is usually a connectivity or cross-origin story, not a backend one (Network Failures Only the Client Can See).
Slow device, slow network, large data, old tab
  • On a high-latency network, headers arrive long before the body finishes. The promise resolves early and the parse happens much later, so a naive "loading" flag flips off before there is anything to show (Bandwidth vs Latency in Networking).
  • On a slow device, parse and render dominate everything the network did. The same response that renders instantly on a laptop is a visible stall on a mid-range phone (Interaction Responsiveness).
  • On a large payload, the parse itself becomes the long task. Pagination is often a responsiveness fix rather than a bandwidth fix (Pagination From the Interface Backwards).
  • Behind a captive portal, a request can return 200 with a login page. Every assumption in the happy path is satisfied and the data is still wrong.
What this costs
  • A strict fetch wrapper is indirection: every request now goes through code you own, and a bug in it is a bug in every screen. It pays for itself the first time you need to add a deadline, a correlation id or an auth refresh in one place.
  • Deadlines cause false failures. A request abandoned at your chosen limit on a genuinely slow connection would have succeeded, so the limit is a judgement about the user's patience, not about the server.
  • Keeping parsed responses costs memory and buys an invalidation problem. Nothing about caching is free, and the cost lands later than the benefit (Query Keys and Invalidation).

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe resolve-on-headers behaviour, the absence of a default timeout and the single-read body are all specified by the Fetch standard, so they hold identically across Blink, Gecko and WebKit. XMLHttpRequest differs on two of the three: it has a timeout property, and it exposes the status without requiring you to read the body first.
  • BROWSER-SPECIFICWhat the Network panel shows for a blocked cross-origin request differs: Chromium reports a specific CORS failure reason in the request detail, Firefox puts the explanation in the console rather than the request row, and Safari is the tersest of the three — so the same failure looks like three different bugs depending on where you debug it.
  • NETWORK-SPECIFICConnection reuse, request prioritisation and the cost of an extra request all depend on the protocol version. On HTTP/1.1 a sixth parallel request to one origin queues behind the others; on HTTP/2 and HTTP/3 it does not, which changes whether splitting one request into three is a good idea (HTTP/1.1 vs HTTP/2 vs HTTP/3 in Networking).

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Distributed Systems — "the server did not answer" and "the server answered and the answer was lost" are indistinguishable from a browser, which is why a client can never know whether a mutation took effect without asking again.