completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Streams & progress

Read fetch responses as Uint8Array chunks, show honest download progress, decode streamed text, and choose when upload progress needs XMLHttpRequest.

You will learn to
  • 01
    Read chunksRead a response body piece by piece with response.body.getReader(), and know what done and value mean.
  • 02
    Show honest progressUse Content-Length for a percentage when it is there, and show bytes only when the total is unknown.
  • 03
    Build 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.
Real-life analogyA water tank or a pipe

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 ReadableStream gives you chunks over time
In real life: A full water tank is delivered
In JavaScript: response.json() or response.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

MODEL

response.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 Content response, which means "done, nothing to send back",
  • a response to a HEAD request, which asks for the headers only.

The table compares the two ways to read a body. Each row asks one question about both ways.

Read the whole body at once, or read it as a stream
QuestionWhole body at onceStream 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:

  • done is false while there are chunks, and true at the end.
  • value is the next chunk of bytes, or undefined at the end.
Read one chunkPop out in the code editor (opens in a new tab)JavaScript
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.

The body counts as used after the first readPop out in the code editor (opens in a new tab)JavaScript
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.

Read every chunk in a loopPop out in the code editor (opens in a new tab)JavaScript
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 stream comes 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 done is true.
  • Line 8 turns the chunk into text and adds it to text.
  • Line 10 finishes the decoding, and line 11 prints Hi 🌊!.
Four reads from the stream in the read loop. Read 1 gives 4 bytes, read 2 gives 2 bytes, and read 3 gives 2 bytes, each with done false. Read 4 gives undefined with done true, so the loop stops.read 14 bytesdone: falseread 22 bytesdone: falseread 32 bytesdone: falseread 4undefineddone: trueloop stops
The stream in the loop has three chunks: 4, 2, and 2 bytes. The fourth read() gives done: true, so the loop stops.

Show download progress

INTERACTIVE

A 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.

Real-life analogyTrain carriages at a station

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-Length gives the total size
In real life: There is no timetable
In JavaScript: Count what arrives, but show no percentage
Count bytes as chunks arrivePop out in the code editor (opens in a new tab)JavaScript
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.

The received box starts at 0. Chunk 1 adds 3 bytes, so it holds 3. Chunk 2 adds 2 bytes, so it holds 5.0receivedstart3received3 bytes5received5 bytes+3 bytes+2 bytes
received starts at 0, and each chunk adds its size. So the loop prints 3 bytes, then 5 bytes.
Slow server download
A slow server inside the pagePop out in the code editor (opens in a new tab)JavaScript
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) } : {},  });}
Progress0%

0 bytes received of 24,000

  1. 1Pick the settings, then press Start. The slow response is made inside this page.
Try it yourself

The bar shows 0 of 24000 bytes. Without the Content-Length header, it could only count bytes, with no percentage.

The response is made inside this page with 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.

Stop reading after the first chunkPop out in the code editor (opens in a new tab)JavaScript
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 THROUGH

Bytes 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 }). The stream: true option 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.
Decode with TextDecoderStreamPop out in the code editor (opens in a new tab)JavaScript
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 read loop
Step 0 of 12Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
Decoder setting

Both runs use real bytes from TextEncoder. Without stream: true, the split wave emoji breaks.

A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

The bytes of Hi wave! arrive in three chunks: 72 105 32 240, then 159 140, then 138 33. With stream true the decoder gives Hi, then nothing, then wave and exclamation mark. Without it, the broken bytes of the emoji turn into replacement characters.chunksstream: truewithout it72 105 32 240"Hi ""Hi �"159 140"""��"138 33"🌊!""�!"
The emoji's 4 bytes are split over three chunks. With stream: true, the decoder waits for all 4; without it, the broken bytes turn into �.

Read a real page as a stream

REAL FETCH

The 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-Length and Content-Encoding headers, if the browser lets your code see them (Content-Encoding says whether the body was compressed),
  • how many chunks arrived,
  • how many bytes arrived.
Read this page as chunks
Count the chunks of this pagePop out in the code editor (opens in a new tab)JavaScript
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 });
Browser resultready
  1. 1Press the button to fetch this lesson page from this same site.
Try it yourself

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.

The lesson fetches only its own site, never another website.
Why only this site?

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

PIPELINE

You 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.

Real-life analogyBackpressure is a busy kitchen

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.

source.pipeThrough(uppercase). The source chunks ada and lovelace go into the uppercase box, which holds toUpperCase. Out come ADA and LOVELACE.source.pipeThrough(uppercase)"ada""lovelace"sourcetoUpperCase()uppercase"ADA""LOVELACE"transformed
pipeThrough sends each chunk from source through the uppercase TransformStream. So "ada" and "lovelace" come out as "ADA" and "LOVELACE".
Make and change a stream
A ReadableStream through a TransformStreamPop out in the code editor (opens in a new tab)JavaScript
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);}
Outputchunks
  1. 1Run the pipeline, try tee, or check whether this browser supports for await on streams.
Try it yourself

The ReadableStream makes chunks. The TransformStream changes each chunk. pipeThrough connects the two. tee splits one stream into two branches.

Node 22 and newer browsers support for await on a stream. Not every browser does, so the page checks first.
Split one stream into two with teePop out in the code editor (opens in a new tab)JavaScript
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 LIMIT

Download 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.

Upload progress with XHR
Upload progress events with XHRPop out in the code editor (opens in a new tab)JavaScript
// 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);
Pretend eventsXHR
  1. 1Press the button. This lesson has no server to upload to, so the events are pretend ones.
Try it yourself

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.

fetch can send a stream as the request body with 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

SORT

Streams 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.

Stream it or wait for all?
  • 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
Try it yourself
0 of 6 correct

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?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Each row of the table is a common task. Read across to see what to use and why.

What to use for common tasks
TaskWhat to useWhy
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 TextDecoderStreamEach 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.body is null when 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 Uint8Array bytes. Use a TextDecoder to turn them into text.
  • “fetch has upload progress because it has download streams.” No. fetch has no upload progress events. Use XHR when you need them.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the percentages

Work out each percentage in your head. Then run the code to check.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const total = 1000;
let received = 0;
for (const chunk of [200, 300, 500]) {
  received += chunk;
  console.log(Math.round((received / total) * 100) + "%");
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFind the total size

    Your page wants to show a percentage. Which response header gives the total number of bytes?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeIs the body used yet?

      Predict the two values that print. Try it in a browser console or in Node 22.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const response = new Response("hello");
      const reader = response.body.getReader();
      console.log(response.bodyUsed);
      await reader.read();
      console.log(response.bodyUsed);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeDecode text that is split

        Which decoder option stops a split emoji from turning into replacement characters?

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        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());

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePick the right tool for upload progress

          Your team wants a progress bar while a large file uploads. Which browser API gives you upload progress events?

          Answer, then press Check. Spacing and letter case don’t matter.

            Quiz

            7 QUESTIONS
            Streams & progress quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is response.body for a normal fetch response?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            3. Question 3 of 8When can you show a download percentage you can trust?

              Choose an answer to see the explanation.

            4. Question 4 of 8Which line turns chunks into text without breaking characters that are split across chunks?

              Choose an answer to see the explanation.

            5. Question 5 of 8What does getReader() do to a stream?

              Choose an answer to see the explanation.

            6. Question 6 of 8Which API gives real upload progress events in browsers?

              Choose an answer to see the explanation.

            7. Question 7 of 8What should your code do when Content-Length is missing?

              Choose an answer to see the explanation.

            8. Question 8 of 8What does reader.cancel() tell the stream's source?

              Choose an answer to see the explanation.

            Key takeaways

            • response.body is a ReadableStream of Uint8Array chunks, 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 TextDecoder with { stream: true }, or TextDecoderStream.
            • Use XHR for real upload progress events. fetch streams 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.

            CompleteFrontend Clear concepts. Working examples.