Most developers write await fetch(url) and mentally treat it like "wait until the whole response is ready." That is not what actually happens.
The Fetch API is deliberately split into two steps. The first step resolves the network request into a Response object as soon as the browser has the status line and headers. The second step consumes the body, usually through response.json(), response.text(), response.blob(), response.arrayBuffer(), or direct stream access.
That split is not a quirk. It is the design. Once you understand it, a lot of fetch behavior stops looking strange: why fetch() resolves before the payload is fully downloaded, why response.ok can be checked before parsing, why large downloads can stream progressively, and why the body can only be consumed once unless you clone the response.
The short version
1 Wait for the response object
await fetch(request) resolves when response metadata is available.
2 Inspect status and headers
You can branch on ok, status, and headers before touching the body.
3 Consume the body separately
response.json() and friends are a second async step with their own failure modes.
const response = await fetch('/api/articles');
const articles = await response.json();
The first await does not mean the JSON is ready. It means the response started and headers are available. The second await is where the body is actually read and decoded.
Step one: fetch() resolves to response metadata
When fetch() resolves, you get a Response object with information such as status, statusText, ok, headers, and url. At that point, the body may still be in flight.
const response = await fetch('/api/user/42');
if (!response.ok) {
throw new Error('Request failed with status ' + response.status);
}
const user = await response.json();
That ordering is intentional. The platform wants you to be able to react to protocol-level information before committing to a potentially expensive body read. This matters when the payload is large, when the format depends on headers, or when you want to reject early on non-2xx responses.
Step two: the body is read separately
The Response object exposes body readers such as json(), text(), blob(), and arrayBuffer(). These methods each represent a second async operation because the browser may still need to download, buffer, decode, and parse the body.
| Reader | What happens after the response object exists |
|---|---|
| response.json() | Reads the stream, decodes bytes as text, then parses JSON. |
| response.text() | Reads the stream and decodes it as text. |
| response.blob() | Buffers the body into a Blob for binary or file-like use. |
| response.arrayBuffer() | Reads the body into raw bytes. |
If the payload is malformed JSON, the first await succeeds and the second one fails. That is an important distinction: transport success and body-parsing success are not the same event.
Why the split exists
That is why code can inspect content-type before deciding how to read the body.
const response = await fetch('/report');
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
throw new Error('Expected JSON but received ' + contentType);
}
const report = await response.json();
The most common misunderstanding
Many codebases accidentally treat await fetch() as if it already delivered application data. It has not. It delivered a response wrapper that still needs a second step.
That confusion also leads to sloppy error handling. A single catch block may end up mixing together request failures, aborts, stream read failures, and JSON parse failures even though those problems happen at different points in the lifecycle.
const response = await fetch('/api/items');
if (!response.ok) {
throw new Error('HTTP error ' + response.status);
}
try {
return await response.json();
} catch {
throw new Error('Response body was not valid JSON');
}
Streaming makes the design obvious
The cleanest way to see the two-step model is to read the stream directly. Once fetch() resolves, the browser can expose response.body immediately while chunks continue arriving.
const response = await fetch('/api/logs/live');
if (!response.body) {
throw new Error('ReadableStream not available');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) {
const chunk = decoder.decode(result.value, { stream: true });
console.log(chunk);
}
}
If fetch were a single promise for the full payload, this progressive consumption model would be much harder to express. The early Response object is what makes streaming ergonomic.
Why the body can be consumed only once
The response body is stream-based, so reading it usually consumes it. There is not a hidden second copy waiting for another parser. That is why this fails after the first body reader runs.
const response = await fetch('/api/article');
const raw = await response.text();
const parsed = await response.json();
If you need two consumers, clone the response before reading either copy.
const response = await fetch('/api/article');
const copy = response.clone();
const raw = await response.text();
const parsed = await copy.json();
Error handling across both steps
Another behavior that stops being surprising once you adopt this model is that fetch() does not reject on HTTP 404 or 500. A non-2xx response is still a valid HTTP response, so step one succeeds and hands you a Response. Your code must decide whether that status is acceptable.
1 fetch() rejects on transport failure
Think DNS failure, network interruption, CORS rejection, or explicit aborts.
2 404 and 500 still produce Response
The request completed at the HTTP layer, so you still receive status and headers.
3 Body parsing can fail afterward
A truncated or malformed payload can break even when the server returned 200 OK.
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error('HTTP ' + response.status + ' for ' + url);
}
return response.json();
}
Abort behavior fits the same model
Abort signals also make more sense once you stop thinking of fetch as one monolithic promise. Cancellation can happen before response metadata arrives or while the body is still streaming. In other words, the operation spans both phases.
const controller = new AbortController();
const request = fetch('/api/export', {
signal: controller.signal,
});
controller.abort();
await request;
Practical rules for everyday code
Final takeaway
fetch() is a two-step process because HTTP itself is a two-phase interaction: metadata first, body second. The API preserves that structure on purpose.
That design gives you early access to headers, enables streaming, keeps parsing separate from transport, and avoids forcing every response into one eager buffering model. So when you see const response = await fetch(url) followed by await response.json(), read it literally: those are two waits for two different things.