The Developer's Quarterly · Web APIsVol. III · May 2026

fetch() Internals:Why It Is a Two-Step Process by Design

The Fetch API resolves in two distinct phases by design: first you receive HTTP metadata as a Response object, then you separately consume the body, which is what makes early status checks, streaming, and precise error handling possible.

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.

Two awaits for two different phases
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.

Check HTTP state before reading the payload
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

Headers arrive first HTTP already separates response metadata from the response body, and fetch mirrors that structure.
Streaming stays possible A response can become usable before the entire payload is buffered in memory.
Parsing stays explicit Your code chooses how to decode the body instead of forcing one eager format for every request.
Memory stays flexible Large responses can be streamed or processed incrementally rather than copied into a giant string immediately.

That is why code can inspect content-type before deciding how to read the body.

Branch on headers first
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.

Separate HTTP errors from parse errors
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.

Read a streaming response chunk by chunk
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.

One-shot body consumption
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.

Clone before double-reading
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.

A small helper that respects both phases
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.

Abort an in-flight fetch
const controller = new AbortController(); const request = fetch('/api/export', { signal: controller.signal, }); controller.abort(); await request;

Practical rules for everyday code

Treat fetch as metadata first Do not assume the payload is ready just because fetch() resolved.
Check ok before parsing This keeps status handling separate from body decoding.
Pick the right reader Use json(), text(), blob(), or the stream based on the expected payload.
Clone only when necessary Double consumption has a cost, so keep it explicit.

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.