Case 8/13

Prefer reading? The full explanation

How fetch Runs

await over the network · Browser

Before this

How await Runs — suspension and continuations.

After this you can explain

Why await fetch() resolves before the body has downloaded, and why a 404 does not throw.

console.log('1: start');

async function load() {
  console.log('2: inside load');
  const res = await fetch('/api/user');
  console.log('4: headers arrived');
  const data = await res.json();
  console.log('5: body parsed');
}

load();

console.log('3: script end');

Output: 1: start → 2: inside load → 3: script end → 4: headers arrived → 5: body parsed

What happens during those 200ms?

The output is 1 → 2 → 3 → 4 → 5. The interesting parts are the two gaps, and the fact that there are two of them.

Calling fetch transfers no data on the main thread. It hands the request to the browser's network layer, which works on other threads, and returns a pending promise immediately. The await suspends load(), control returns to the script, 3 prints, and the thread goes completely idle — free to handle clicks, animations and timers while the request travels.

Why there are two awaits

This is the detail most explanations skip. await fetch(...) resolves as soon as the response headers are available — the status line and headers, nothing more. At that moment the body may not have arrived at all, which is precisely what makes streaming responses possible.

res.json() is therefore a second asynchronous operation. It does not start the download — the body may already be arriving and buffering the moment the headers are done. What json() starts is the consumption: reading that stream to the end and parsing it, returning its own promise. In the visualisation the Web APIs panel switches from "awaiting headers" to "reading + parsing response body", with the microtask queue empty during both waits.

Practically: timing await fetch() roughly measures how long until the response headers become available — which includes redirects, scheduling and browser processing, so it is not the same thing as the TTFB metric in your performance tooling. Either way it is not download time. The cost of the body shows up on the .json() line.

Where the continuation waits

While either request stage is in flight, the microtask queue is empty. The rest of load() is not queued anywhere; it is parked on a promise that has not settled.

Only when that promise resolves does the continuation become an ordinary microtask, and from then on it follows the rules from the earlier cases exactly: it runs when the call stack is empty, ahead of any pending timer. A response served from cache in under a millisecond still cannot overtake the synchronous script.

A 404 does not make fetch reject

A 404 is certainly an error in the HTTP sense — it just is not a promise rejection, which is the distinction that matters here. fetch rejects only when the request itself fails: DNS failure, CORS rejection, dropped connection. Any response the server actually sends, including 404 and 500, resolves successfully with response.ok set to false.

This surprises people who wrap fetch in try/catch and assume server errors will land in the catch block. They will not. The experiment below traces a 404 all the way through: the promise resolves, the continuation runs, and it is entirely your job to check response.ok or response.status before touching the body.

Reading about it is one thing. Watching it run is another.

↑ Run it step by step

In the wild

Where this rule bites real code

The error handler that never fires

Symptom
Your API starts returning 500s. Users see broken data, but your try/catch around await fetch() logs nothing and your error tracker stays green.
Why
fetch resolves on any completed HTTP exchange — 404 and 500 included. Only network-level failures (DNS, CORS, dropped connection) reject and reach catch.
Fix
Check response.ok immediately and throw new Error(response.status) yourself. Do it in one wrapper function so no call site can forget.

The request that outlives its page

Symptom
A user types in a search box; responses come back out of order and an old query's results overwrite the new ones.
Why
Resolving a fetch promise cannot be un-queued. Every in-flight request will eventually run its continuation, in network-completion order, whether you still want it or not.
Fix
Pass an AbortController signal to fetch and call .abort() when a newer request starts. The stale request rejects with an AbortError, so its success path never runs — just remember your catch will see that AbortError and should ignore it.

Environment

Browser

CI verified

Node.js v22.22.3

network transport stubbed; both async stages (headers, then body) preserved

Last reviewed

29 July 2026

The animation on this page is a teaching model, not a diagram of browser internals. Real engines use several task queues, optimise aggressively, and do plenty of work these panels do not show. What is guaranteed is the observable ordering: the stated output is produced by actually executing the code in CI, on the runtime named above. Browser-specific behaviour follows the HTML Standard and is not executed in a real browser by CI. How this is verified →