Lesson 58 of 80
await res.json() waits for the whole body. res.body is a ReadableStream that lets you process bytes as they arrive — the difference matters the moment a response gets large or slow.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
await fetch(url).then(res => res.json()) is the version everyone learns first, and it's correct almost all the time — but it has a hidden assumption: the entire response body has to arrive and get buffered in memory before you get anything back. For a small JSON payload that's irrelevant. For a large file, a long-running AI response being generated token by token, or a live feed, it means the user waits for the whole thing before seeing anything at all.
Every Response object has a .body property that's a ReadableStream — the same streaming primitive used elsewhere on the platform. Reading from it directly gets you chunks of bytes as they arrive over the network, instead of waiting for the connection to close.
This is the actual shape of consuming a streaming chat completion — read chunks as they arrive, decode them from bytes to text, and render incrementally:
async function streamCompletion(prompt, onChunk) {
const res = await fetch("/api/ai/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
onChunk(text); // render this piece immediately
}
}
streamCompletion("Explain closures", (chunk) => {
outputEl.textContent += chunk; // grows as tokens arrive
});
res.body.getReader() gives you a ReadableStreamDefaultReader, whose .read() returns { done, value } — value is a raw Uint8Array of whatever bytes have arrived so far, not text. TextDecoder converts those bytes to a string, and the { stream: true } option matters: it tells the decoder to hold onto any incomplete multi-byte character at a chunk boundary and finish decoding it once the rest arrives, instead of producing garbled output.
.json(), .text(), or .body.getReader() — once consumed, the stream is done. Calling res.json() a second time, or trying to read the body two different ways, throws..json() and .text() are just streaming under the hood, done for you. They call .body.getReader() internally, accumulate every chunk, then parse or decode the result. Reaching for the raw stream is only necessary when you need to act on partial data before the whole response has arrived.break out of the read loop early because you have what you need, call reader.cancel() (or use an AbortController's signal on the original fetch) to actually stop the network transfer — otherwise the response keeps downloading in the background.reader.read() returning { done, value } is the exact protocol the generators lesson introduces for .next() — a ReadableStreamDefaultReader is, structurally, an async iterator. And the concern about a reader not stopping the network transfer on its own is the same "the operation decides how to react, cancellation is a signal, not a command" idea AbortController formalizes — pairing an AbortController's signal with a fetch call is the actual way to stop an in-flight streamed response, not just abandoning the reader.