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