Streams & progress
Read fetch responses as Uint8Array chunks, show honest download progress, decode streamed text, and choose when upload progress needs XMLHttpRequest.
- 01Read chunksUse
response.body.getReader()and understand locking,done, andvalue. - 02Show honest progressUse
Content-Lengthwhen present and fall back when the size is unknown or compressed. - 03Build stream pipelinesCreate
ReadableStreams, transform chunks, tee streams, and decode text safely.
Streams are bodies you can read early
A normal await response.json() waits for the whole response body, then parses it. That is perfect for a small JSON object. A stream lets your code read the body little by little as bytes arrive, so the interface can show progress, append live text, or process a large file without holding one giant string first.
Imagine you are thirsty. A tank arrives all at once, but a pipe lets you start drinking as soon as the first drops arrive. Streams give JavaScript that pipe-shaped access to response bodies.
- In real life: Water through a pipe
- In JavaScript: A
ReadableStreamdelivers chunks over time - In real life: Delivered water tank
- In JavaScript:
response.json()orresponse.text()waits for the whole body - In real life: First drops are usable
- In JavaScript: The first
reader.read()result can be processed immediately
Where the analogy stops: Real network layers may buffer, compress, cache, or coalesce data. A stream gives JavaScript chunk access after the browser exposes the bytes; it does not guarantee one server write equals one JavaScript chunk.
In this lesson, a chunk is usually a Uint8Array: raw bytes. You can count them for progress, decode them into text, or pipe them through transforms.
response.body as a ReadableStream
MODELFor a fetch response with a body, response.body is a ReadableStream of Uint8Array chunks. It may be null for responses that do not have bodies, such as 204 No Content or HEAD responses.
| Question | Whole body | Stream reader |
|---|---|---|
| API | await response.json() / text() / arrayBuffer() | const reader = response.body.getReader() |
| Timing | Finishes when the complete body has arrived and been parsed or assembled. | Each reader.read() resolves with the next chunk or { done: true }. |
| Body use | Consumes the body once. | Also consumes the body once; body methods cannot be used afterward. |
| Best for | Small complete results. | Progress, large files, live logs, streamed text. |
getReader() locks the stream. One stream has one active reader at a time. When you finish, cancel, or release the lock, another reader may take over if the body has not already been consumed. If you need two consumers from the beginning, clone the Response before reading or use stream.tee().
const response = new Response(stream);const reader = response.body.getReader();const decoder = new TextDecoder();let text = "";while (true) { const { done, value } = await reader.read(); if (done) break; text += decoder.decode(value, { stream: true });}text += decoder.decode();console.log(text);Download progress
INTERACTIVEProgress is just counting bytes. If the response exposes a trustworthy Content-Length, the percentage is received / total. If the header is missing, the honest UI is indeterminate: show bytes and chunks, but not a fake percentage.
The reader is like a station worker unloading carriages. Each read() says either “here is the next carriage” or “the train is finished.”
- In real life: Train carriages
- In JavaScript: Chunks arriving one by one
- In real life: Station worker
- In JavaScript: The reader that calls
read() - In real life: Timetable with total carriages
- In JavaScript:
Content-Lengthgives a total - In real life: No timetable
- In JavaScript: Count arrivals, but do not claim a percent
Where the analogy stops: Trains have clear carriage boundaries. Network chunks are chosen by the browser and network stack, so they may not match server writes or file records.
function makeSlowResponse({ totalBytes, chunkBytes, delayMs, contentLength }) { let sent = 0; const stream = new ReadableStream({ async pull(controller) { await wait(delayMs); const size = Math.min(chunkBytes, totalBytes - sent); controller.enqueue(new Uint8Array(size).fill(65)); sent += size; if (sent >= totalBytes) controller.close(); }, cancel() { console.log("reader cancelled the slow server"); }, }); return new Response(stream, { headers: contentLength ? { "Content-Length": String(totalBytes) } : {}, });}0 bytes received of 24,000
- 1
Choose settings, then start the slow in-page response.
The progress bar can use 0 / 24000 bytes. If the header is missing, the UI must count bytes without a percentage.
new Response(readableStream). No external URL is fetched.Try hiding Content-Length. The same bytes arrive, but the progress bar cannot know the total. Also remember that real servers can send compressed responses: a Content-Length might describe compressed bytes while JavaScript receives decoded bytes, so progress can look strange or exceed 100%.
Reading chunks into text
STEP THROUGHBytes are not characters. UTF-8 characters can use more than one byte, and a stream chunk can split a character in the middle. TextDecoder.decode(chunk, { stream: true }) keeps partial bytes until the next chunk. TextDecoderStream does the same job in a pipeline.
Step through the body-reading loop. Switch the decoder setting and watch what happens when an emoji is split across chunks.
script
const reader = response.body.getReader();const decoder = new TextDecoder();let text = "";while (true) { const { done, value } = await reader.read(); if (done) break; text += decoder.decode(value, { stream: true });}text += decoder.decode();console.log(text);The split emoji is the important moment. Without the stream option, the decoder must finish each chunk immediately, so incomplete bytes become replacement characters. With the stream option, it waits.
Real same-origin streaming
REAL FETCHThe slow server playground is controlled, but the browser can also read a real same-origin response as a stream. This experiment fetches location.href, then reports the exposed headers, chunk count, and byte count from your browser.
const response = await fetch(location.href);const total = Number(response.headers.get("Content-Length"));const reader = response.body?.getReader();let received = 0;let chunks = 0;while (reader) { const { done, value } = await reader.read(); if (done) break; received += value.byteLength; chunks += 1;}console.log({ received, chunks, total: Number.isFinite(total) ? total : null });- 1
Press Run to fetch this lesson page from the same origin.
This uses the browser's real fetch and the server's real headers for the current page. Some hosts omit Content-Length or report compressed size.
A static course page should not fetch external URLs. Same-origin requests are safe here and avoid CORS surprises. Cross-origin streams work only when the other origin explicitly permits the browser to expose the response.
Streams API basics
PIPELINEA ReadableStream has an underlying source. start runs when the stream is created, pull runs when the consumer wants more, and cancel runs when the consumer gives up. This is where backpressure begins: the consumer’s demand controls when more chunks are requested.
If the kitchen counter is full, the waiter should stop bringing plates. Streams have the same idea: do not push unlimited chunks into memory when the next step is slow.
- In real life: Waiter asks for dishes
- In JavaScript: The reader or pipe asks for chunks
- In real life: Counter is full
- In JavaScript: The consumer cannot keep up
- In real life: Kitchen slows down
- In JavaScript: Backpressure tells the source not to enqueue endlessly
Where the analogy stops: Streams expose queue sizes and pull signals, not human conversations. The source must cooperate for backpressure to save memory.
const source = new ReadableStream({ start(controller) { controller.enqueue("ada"); }, pull(controller) { controller.enqueue("lovelace"); controller.close(); }, cancel(reason) { console.log("cancelled", reason); },});const uppercase = new TransformStream({ transform(chunk, controller) { controller.enqueue(chunk.toUpperCase()); },});const transformed = source.pipeThrough(uppercase);for await (const chunk of transformed) { console.log(chunk);}- 1
Run the stream pipeline or check async-iteration support.
ReadableStream produces chunks. TransformStream changes each chunk. pipeThrough connects them. tee splits one stream into two branches.
pipeThrough connects a readable stream to a TransformStream. tee() splits one stream into two branches. for await (const chunk of stream) is pleasant in Node 22 and newer browsers, but feature-detect it on the page because some browsers may not support async iteration on ReadableStream.
Upload progress
HONEST LIMITDownload progress uses response.body. Upload progress is different: browser fetch does not provide upload progress events. XMLHttpRequest still has xhr.upload.onprogress, which is why upload widgets often use XHR even in modern apps.
// fetch has download streams, but no upload progress events.const xhr = new XMLHttpRequest();xhr.upload.onprogress = (event) => { if (event.lengthComputable) { console.log(Math.round((event.loaded / event.total) * 100) + "%"); } else { console.log(event.loaded + " bytes uploaded"); }};xhr.open("POST", "/same-origin-upload");xhr.send(file);- 1
Press Simulate. This static lesson has no upload endpoint.
fetch does not expose upload progress events. XHR's xhr.upload.onprogress does, but this static page has no POST endpoint, so the demo simulates the progress events and labels them honestly.
duplex: 'half' in limited environments; they are not a cross-browser upload progress UI.Streaming request bodies with fetch and duplex: "half" exist in limited environments, especially Chromium-based browsers, and they are not a general upload progress event API. The Cancellation lesson covers aborting requests; the XMLHttpRequest lesson comes soon.
Where you will use this
SORTStreams are not “better fetch.” They are a lower-level tool. Reach for them when early chunks are useful. Wait for the whole body when the complete value is small and easier to reason about.
- Show progress for a big video download
- Parse a tiny settings JSON object
- Display AI text or a server log as it arrives
- Compute a hash of the whole file
- Load a 6 KB avatar image
- Process a newline-delimited data export
Choose whether each task benefits from chunk-by-chunk streaming, is clearer after waiting for all data, or can use either approach with a tradeoff.
| Task | Usually choose | Reason |
|---|---|---|
| Small JSON API response | await response.json() | You need the whole object and the code is clearer. |
| Large download with progress | response.body.getReader() | You can update progress after each chunk. |
| Live text output | Stream + TextDecoder or TextDecoderStream | Text can appear as soon as chunks arrive. |
| File hash | Stream into an incremental hasher | The final answer needs all bytes, but you do not need to store all bytes. |
Common misconceptions
TRAPS- “Every response has a body stream.” No.
response.bodycan benull, especially for 204 and HEAD. - “Content-Length always means perfect progress.” It can be absent, hidden by CORS, or describe compressed bytes while JavaScript sees decompressed bytes.
- “I can stream and then call json.” Reading from the stream consumes the body. Use one body-reading strategy or clone before reading.
- “Chunks are strings.” Fetch body chunks are
Uint8Arraybytes. Decode text carefully. - “fetch has upload progress because it has download streams.” It does not expose upload progress events; use XHR when you need them.
Practice exercises
5 EXERCISESRun the percentages in your head before checking.
const total = 1000;
let received = 0;
for (const chunk of [200, 300, 500]) {
received += chunk;
console.log(Math.round((received / total) * 100) + "%");
}const total = 1000;
let received = 0;
for (const chunk of [200, 300, 500]) {
received += chunk;
console.log(Math.round((received / total) * 100) + "%");
}The chunks bring the total to 200, then 500, then 1000 bytes. Those are 20%, 50%, and 100%.
Your UI wants to show a percentage. Which header gives the total byte count?
Content-Length is the header that gives a byte total. If it is missing, show bytes or an indeterminate bar instead of a percentage.
Predict the two booleans. Try it in a browser console or Node 22.
const response = new Response("hello");
const reader = response.body.getReader();
console.log(response.bodyUsed);
await reader.read();
console.log(response.bodyUsed);const response = new Response("hello");
const reader = response.body.getReader();
console.log(response.bodyUsed);
await reader.read();
console.log(response.bodyUsed);bodyUsed starts false. After reader.read() receives bytes, the response body has been consumed, so it becomes true.
Which decoder option prevents split emoji bytes from turning into replacement characters?
const bytes = new TextEncoder().encode("A 🌊 B");
const decoder = new TextDecoder();
console.log(decoder.decode(bytes.slice(0, 4), { stream: true }));
console.log(decoder.decode(bytes.slice(4), { stream: true }) + decoder.decode());Use decoder.decode(chunk, { stream: true }) for intermediate chunks, then call decoder.decode() once at the end to flush.
A product manager asks for a progress bar while a large file uploads. Which browser API has the progress event?
// fetch has download streams, but no upload progress events.
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
console.log(Math.round((event.loaded / event.total) * 100) + "%");
} else {
console.log(event.loaded + " bytes uploaded");
}
};
xhr.open("POST", "/same-origin-upload");
xhr.send(file);Use XMLHttpRequest when you need real browser upload progress events. A fetch request body stream is not the same feature.
Quiz
7 QUESTIONSQuestion 1 of 7What is
response.bodyfor a normal fetch response?Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictconst response = new Response("hello"); const reader = response.body.getReader(); console.log(response.bodyUsed); await reader.read(); console.log(response.bodyUsed);Choose an answer to see the explanation.
Question 3 of 7When can you show a reliable download percentage?
Choose an answer to see the explanation.
Question 4 of 7Which line releases partial multi-byte characters safely?
Choose an answer to see the explanation.
Question 5 of 7What does
getReader()do to a stream?Choose an answer to see the explanation.
Question 6 of 7Which API has real upload progress events in browsers?
Choose an answer to see the explanation.
Question 7 of 7How should code handle a missing Content-Length?
Choose an answer to see the explanation.
Key takeaways
response.bodyis aReadableStreamofUint8Arraychunks when a body exists.getReader()locks the stream, and reading consumes the body.- Download percentages need a reliable total, usually
Content-Length; otherwise show bytes only. TextDecoderneeds{ stream: true }orTextDecoderStreamfor text split across chunks.- Use XHR for real upload progress events; fetch only exposes download body streams.
Final definition: streaming is reading a response body chunk by chunk so JavaScript can process bytes before the complete body has arrived.
Up next: WebSocket & Server-Sent Events.