Streams & progress
Read fetch responses as Uint8Array chunks, show honest download progress, decode streamed text, and choose when upload progress needs XMLHttpRequest.
- 01Read chunksRead a response body piece by piece with
response.body.getReader(), and know whatdoneandvaluemean. - 02Show honest progressUse
Content-Lengthfor a percentage when it is there, and show bytes only when the total is unknown. - 03Build stream pipelinesMake your own
ReadableStream, change chunks on the way, split a stream in two, and turn bytes into text safely.
Streams: read a body piece by piece
When you call fetch(), the server sends back a response. The response has a body: the data itself, such as JSON, text, or a file.
await response.json() waits until the whole body has arrived. Then it turns the body into a JavaScript value. That works well for a small JSON object.
A stream is another way to read the body. Your code gets the body in small pieces, called chunks, as they arrive. With a stream, you can:
- show a progress bar while a big file downloads,
- show text on the page as soon as it arrives,
- work through a large file without keeping all of it in memory at once.
Imagine you are thirsty. A water tank is delivered only when it is full. A pipe lets you drink as soon as the first drops come out. A stream gives your code the pipe.
- In real life: Water flows through a pipe
- In JavaScript: A
ReadableStreamgives you chunks over time - In real life: A full water tank is delivered
- In JavaScript:
response.json()orresponse.text()waits for the whole body - In real life: You drink the first drops right away
- In JavaScript: You can use the first chunk from
reader.read()right away
In this lesson, a chunk is usually a Uint8Array. A Uint8Array is a list of bytes: whole numbers from 0 to 255. You can count the bytes to show progress, or turn them into text.
response.body is a stream
MODELresponse.body is the body as a stream. Its type is ReadableStream: a stream that your code reads from. Each chunk in it is a Uint8Array of bytes.
Some responses have no body. Then response.body is null. Two examples:
- a
204 No Contentresponse, which means "done, nothing to send back", - a response to a
HEADrequest, which asks for the headers only.
The table compares the two ways to read a body. Each row asks one question about both ways.
| Question | Whole body at once | Stream reader |
|---|---|---|
| What do you write? | await response.json(), text(), or arrayBuffer(). | const reader = response.body.getReader(), then await reader.read(). |
| When do you get data? | Once, after the whole body has arrived. | Each reader.read() gives the next chunk, or { done: true } at the end. |
| Can you read the body again? | No. A body can be read only once. | No. After you read the stream, json() and text() fail. |
| When is it best? | For small results that you need in full. | For progress bars, big files, live logs, and text that arrives bit by bit. |
getReader() gives you a reader: the object that reads the chunks. It also locks the stream. Locked means only this one reader can read it. When you call reader.releaseLock(), the lock goes away, and another reader can read what is left.
Do you need two readers from the start? You have two choices:
- call
response.clone()before you read, to get a second copy of the response, - call
stream.tee()to split the stream into two. You'll see it near the end of the lesson.
Each reader.read() returns a promise. The promise gives an object with two fields:
doneisfalsewhile there are chunks, andtrueat the end.valueis the next chunk of bytes, orundefinedat the end.
const response = new Response(new Uint8Array([65]));const reader = response.body.getReader();const { done, value } = await reader.read();console.log(done);console.log(value.byteLength);Line 1 makes a response whose body is one byte, the number 65. Line 3 reads the first chunk. Line 4 prints false, because a chunk came back. Line 5 prints 1, because the chunk holds one byte.
response.bodyUsed tells you whether the body has been read. It starts as false.
const response = new Response("tea");console.log(response.bodyUsed);const reader = response.body.getReader();await reader.read();console.log(response.bodyUsed);Line 2 prints false: nothing has been read yet. Line 3 takes a reader. That locks the stream, but it reads nothing. Line 4 reads the first chunk. Now line 5 prints true. A later response.json() would fail, because the body was already used.
To read the whole stream, call read() in a loop until done is true.
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);This loop reads a whole stream and turns its bytes into text. In plain words, line by line:
- Line 1 puts a stream into a response. The
streamcomes from setup.js and holds three chunks of bytes. - Line 2 takes a reader.
- Line 3 makes a
TextDecoder: a tool that turns bytes into text. - Line 6 waits for the next chunk.
- Line 7 stops the loop when
doneistrue. - Line 8 turns the chunk into text and adds it to
text. - Line 10 finishes the decoding, and line 11 prints
Hi 🌊!.
read() gives done: true, so the loop stops.Show download progress
INTERACTIVEA progress bar shows how much of the body has arrived. To make one, count the bytes as each chunk comes in.
To show a percentage, you also need the total size. The server may send it in the Content-Length header. A header is a line of extra information that comes with the response. Content-Length says how many bytes the body has. Then the percentage is received / total * 100.
If the header is missing, you don't know the total. Then show the bytes received, but no percentage. A made-up percentage would be wrong.
A station worker unloads a train one carriage at a time. Each time, the worker learns one of two things: here is the next carriage, or the train is finished. The reader learns the same from each read().
- In real life: Train carriages arrive one by one
- In JavaScript: Chunks arrive one by one
- In real life: A station worker unloads each carriage
- In JavaScript: The reader calls
read()for each chunk - In real life: The timetable says how many carriages
- In JavaScript:
Content-Lengthgives the total size - In real life: There is no timetable
- In JavaScript: Count what arrives, but show no percentage
const chunks = [3, 2];let received = 0;for (const bytes of chunks) { received += bytes; console.log(received + " bytes");}Line 1 lists two chunk sizes: 3 bytes and 2 bytes. Line 4 adds each size to received. Line 5 prints the total so far, so you see 3 bytes and then 5 bytes. A percentage would also need the total size from Content-Length.
received starts at 0, and each chunk adds its size. So the loop prints 3 bytes, then 5 bytes.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
Pick the settings, then press Start. The slow response is made inside this page.
The bar shows 0 of 24000 bytes. Without the Content-Length header, it could only count bytes, with no percentage.
new Response(readableStream). Nothing is fetched from another site.Press Hide Content-Length and start again. The same bytes arrive, but the bar can't know the total, so it shows bytes only.
There is one more catch. Servers often compress a response: they squeeze it smaller so it is faster to send. Then Content-Length may count the squeezed bytes, while your reader gets the larger unsqueezed bytes. So progress can look wrong, or even go past 100%.
Sometimes you want to stop early. The user may leave the page, pick a smaller file, or press Stop. Call reader.cancel() to say you want no more chunks. To end the fetch request itself, pass an AbortController's signal to fetch instead.
const stream = new ReadableStream({ start(controller) { controller.enqueue("first chunk"); }, cancel(reason) { console.log(reason); },});const reader = stream.getReader();console.log((await reader.read()).value);await reader.cancel("user stopped");A stream you make yourself can hold any kind of value, so this chunk is a string. Line 3 puts the chunk "first chunk" into the stream. Line 10 reads it and prints it. Line 11 calls reader.cancel("user stopped"). That runs the stream's cancel function on line 5 with the reason, so line 6 prints user stopped.
Turn byte chunks into text
STEP THROUGHBytes are not letters. A TextDecoder turns bytes into text. It uses UTF-8, the usual way to store text as bytes.
In UTF-8, some characters take more than one byte. The emoji 🌊 takes 4 bytes. A chunk can end in the middle of a character. Then the rest of its bytes come in the next chunk.
There are two ways to handle this:
decoder.decode(chunk, { stream: true }). Thestream: trueoption tells the decoder that more chunks are coming. It keeps a half-finished character until the rest arrives.TextDecoderStream. It does the same job inside a pipeline: you connect it to the stream, and text comes out.
const bytes = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("A ")); controller.enqueue(new TextEncoder().encode("🌊 B")); controller.close(); },});const text = bytes.pipeThrough(new TextDecoderStream());for await (const piece of text) console.log(piece);Lines 3 and 4 put two byte chunks into the stream. Line 8 connects the stream to a TextDecoderStream with pipeThrough. Line 9 prints each text chunk: "A " and then "🌊 B".
TextDecoderStream handles split characters for you. A TextDecoder in your own loop gives you more control over each read.
Step through the loop that reads the body. Switch the decoder setting and watch what happens to an emoji whose bytes are 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);Watch the emoji in the step-through. Its 4 bytes are split over three chunks. With stream: true, the decoder waits until it has all 4 bytes. Without it, the decoder must finish each chunk at once. So the broken bytes turn into �, the replacement character.
stream: true, the decoder waits for all 4; without it, the broken bytes turn into �.Read a real page as a stream
REAL FETCHThe slow server above is a fake one, made inside this page. Your browser can also read a real response as a stream. This experiment fetches location.href, the address of this lesson page. Then it shows:
- the
Content-LengthandContent-Encodingheaders, if the browser lets your code see them (Content-Encodingsays whether the body was compressed), - how many chunks arrived,
- how many bytes arrived.
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 the button to fetch this lesson page from this same site.
This uses your browser's real fetch and the real headers that the server sent for this page. Some servers leave out Content-Length, or give the compressed size.
This lesson fetches only its own site. That is called a same-origin request: the same protocol, domain, and port as the page. So the lesson never depends on other websites. You can read a stream from another site only if that site allows it. It allows it with CORS: rules a server sends to say which other sites may read its responses.
Make your own streams
PIPELINEYou can also make your own ReadableStream. You give it an object with up to three functions. This object is called the underlying source:
start(controller)runs once, when the stream is made.pull(controller)runs when the reader wants more chunks.cancel(reason)runs when the reader gives up.
The controller is how the source adds chunks. controller.enqueue(chunk) puts a chunk into the stream's queue, its waiting line of chunks. controller.close() ends the stream.
pull runs only when the reader wants more. So a slow reader gets chunks slowly. This is called backpressure: a slow reader holds back the source, so chunks don't pile up in memory.
In a busy restaurant, the kitchen puts plates on a counter, and waiters carry them out. When the counter is full, the kitchen stops cooking for a while. It waits until there is room again.
- In real life: The waiter asks the kitchen for dishes
- In JavaScript: The reader asks the source for chunks
- In real life: The counter is full of plates
- In JavaScript: The reader can't keep up
- In real life: The kitchen stops cooking for a while
- In JavaScript: The source stops adding chunks for a while
A TransformStream changes each chunk as it passes through. pipeThrough() connects a stream to it and gives back a new stream with the changed chunks. In the lab below, line 18 sends "ada" and "lovelace" through a TransformStream that makes them uppercase.
pipeThrough sends each chunk from source through the uppercase TransformStream. So "ada" and "lovelace" come out as "ADA" and "LOVELACE".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 pipeline, try tee, or check whether this browser supports for await on streams.
The ReadableStream makes chunks. The TransformStream changes each chunk. pipeThrough connects the two. tee splits one stream into two branches.
const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("one")); controller.enqueue(new TextEncoder().encode("two")); controller.close(); },});const [left, right] = stream.tee();console.log(await new Response(left).text());console.log(await new Response(right).text());Line 8 calls tee(). It splits the stream into two branches, left and right. Both branches get the same chunks. Lines 9 and 10 read each branch, so both print onetwo. One thing to know: if one branch is read slowly, the stream keeps its chunks in memory until that branch catches up.
You can also read a stream with for await (const chunk of stream). It gives you one chunk each time round the loop. Node 22 and newer browsers support it, but some browsers don't yet. So the lab checks for it first. The getReader() loop works everywhere.
Upload progress needs XMLHttpRequest
HONEST LIMITDownload progress uses response.body. Upload progress goes the other way: it shows how much of your data has left the browser.
fetch has no upload progress events. An event is a signal that something happened, which your code can listen for. The older XMLHttpRequest, often called XHR, does have them, on xhr.upload.onprogress. That is why many upload widgets still use XHR.
// 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 the button. This lesson has no server to upload to, so the events are pretend ones.
fetch gives no upload progress events. XHR's xhr.upload.onprogress does. This page has no server to receive an upload, so the demo makes up the events and says so.
duplex: 'half', but only in some browsers, and it still gives no progress events.fetch can send a stream as the request body, with the option duplex: "half". But only some browsers support it, mainly Chromium-based ones such as Chrome and Edge. And it still gives no progress events. The Cancellation lesson shows how to stop a request. The XMLHttpRequest lesson is coming soon.
Where you will use this
SORTStreams are not a better fetch. They are a lower-level tool, and they take more code. Use them when early chunks help. Wait for the whole body when it is small and you need all of it.
- Show progress for a big video download
- Read a tiny JSON object of settings
- Show AI text or a server log as it arrives
- Work out a hash (a fingerprint) of a whole file
- Load a 6 KB profile picture
- Go through a big data file one line at a time
Sort each task. Does it gain from reading chunk by chunk? Is it clearer to wait for all the data? Or could it go either way?
Each row of the table is a common task. Read across to see what to use and why.
| Task | What to use | Why |
|---|---|---|
| Read a small JSON reply. | await response.json() | You need the whole object, and the code is simpler. |
| Download a big file with a progress bar. | response.body.getReader() | You can move the bar after each chunk. |
| Show text as it arrives. | A stream with TextDecoder or TextDecoderStream | Each piece of text shows up as soon as its chunk arrives. |
| Work out a file's hash, a short fingerprint made from all its bytes. | Feed the stream, chunk by chunk, into a hasher that accepts data in parts. | The hash needs every byte, but you never have to keep all the bytes at once. |
Common misconceptions
TRAPS- “Every response has a body stream.” Not always.
response.bodyisnullwhen there is no body, for example for a 204 response or a HEAD request. - “Content-Length always gives perfect progress.” The header can be missing. CORS can hide it from your code. And if the server compressed the body, the header may count the compressed bytes, while your reader gets more bytes.
- “I can read the stream and then call json().” No. Reading the stream uses up the body. Pick one way to read it, or call
response.clone()first. - “Chunks are strings.” Fetch body chunks are
Uint8Arraybytes. Use aTextDecoderto turn them into text. - “fetch has upload progress because it has download streams.” No.
fetchhas no upload progress events. Use XHR when you need them.
Practice exercises
5 EXERCISESWork out each percentage in your head. Then run the code to check.
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) + "%");
}After each chunk, received is 200, then 500, then 1000 bytes. Out of 1000, those are 20%, 50%, and 100%.
Your page wants to show a percentage. Which response header gives the total number of bytes?
Content-Length is the header that gives the total size in bytes. If it is missing, show the bytes received, or a bar with no percentage.
Predict the two values that print. Try it in a browser console or in 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 as false. After reader.read() gets bytes, the body has been used, so it is true.
Which decoder option stops a split emoji 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 each chunk. At the end, call decoder.decode() once, with nothing, to finish.
Your team wants a progress bar while a large file uploads. Which browser API gives you upload progress events?
// 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 upload progress events in the browser. Sending a stream as a fetch request body is a different feature.
Quiz
7 QUESTIONSQuestion 1 of 8What is
response.bodyfor a normal fetch response?Choose an answer to see the explanation.
Question 2 of 8What 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 8When can you show a download percentage you can trust?
Choose an answer to see the explanation.
Question 4 of 8Which line turns chunks into text without breaking characters that are split across chunks?
Choose an answer to see the explanation.
Question 5 of 8What does
getReader()do to a stream?Choose an answer to see the explanation.
Question 6 of 8Which API gives real upload progress events in browsers?
Choose an answer to see the explanation.
Question 7 of 8What should your code do when
Content-Lengthis missing?Choose an answer to see the explanation.
Question 8 of 8What does
reader.cancel()tell the stream's source?Choose an answer to see the explanation.
Key takeaways
response.bodyis aReadableStreamofUint8Arraychunks, when the response has a body.getReader()locks the stream, and the first read uses up the body.- A download percentage needs a total you can trust, usually from
Content-Length. Without one, show bytes only. - Text split across chunks needs
TextDecoderwith{ stream: true }, orTextDecoderStream. - Use XHR for real upload progress events.
fetchstreams only help with downloads.
Final definition: streaming means reading a response body chunk by chunk, so your code can use the bytes before the whole body has arrived.
Up next: WebSocket & Server-Sent Events.