cf.completefrontendCode editorOpen lab
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 chunksUse response.body.getReader() and understand locking, done, and value.
  • 02
    Show honest progressUse Content-Length when present and fall back when the size is unknown or compressed.
  • 03
    Build 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.

Real-life analogyStream or water tank

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 ReadableStream delivers chunks over time
In real life: Delivered water tank
In JavaScript: response.json() or response.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

MODEL

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

Whole-body methods vs stream reading
QuestionWhole bodyStream reader
APIawait response.json() / text() / arrayBuffer()const reader = response.body.getReader()
TimingFinishes when the complete body has arrived and been parsed or assembled.Each reader.read() resolves with the next chunk or { done: true }.
Body useConsumes the body once.Also consumes the body once; body methods cannot be used afterward.
Best forSmall 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().

The basic read 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);

Download progress

INTERACTIVE

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

Real-life analogyCarriages at the station

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-Length gives 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.

Slow server download
In-page slow serverPop 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. 1Choose settings, then start the slow in-page response.
Try it yourself

The progress bar can use 0 / 24000 bytes. If the header is missing, the UI must count bytes without a percentage.

The response is created in the page with 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 THROUGH

Bytes 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 read loop
Step 0 of 12Ready
Your turn: follow the blue line

Step through the body-reading loop. Switch the decoder setting and watch what happens when an emoji is split across chunks.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
Decoder mode

Both runs use real bytes from TextEncoder. The unsafe option breaks the split wave emoji.

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.

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 FETCH

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

Real page download
Read this page as chunksPop 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 Run to fetch this lesson page from the same origin.
Try it yourself

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.

Same-origin only: the lesson never fetches external URLs.
Why same-origin?

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

PIPELINE

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

Real-life analogyBackpressure is a busy kitchen

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.

Streams API basics lab
ReadableStream through 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 stream pipeline or check async-iteration support.
Try it yourself

ReadableStream produces chunks. TransformStream changes each chunk. pipeThrough connects them. tee splits one stream into two branches.

Async iteration works in Node 22 and newer browsers; the page feature-detects it because not every browser has it.

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 LIMIT

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

Upload progress honestly
XHR upload progress shapePop 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);
Simulated eventsXHR
  1. 1Press Simulate. This static lesson has no upload endpoint.
Try it yourself

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.

Streaming request bodies with fetch use 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

SORT

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

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

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.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Common choices
TaskUsually chooseReason
Small JSON API responseawait response.json()You need the whole object and the code is clearer.
Large download with progressresponse.body.getReader()You can update progress after each chunk.
Live text outputStream + TextDecoder or TextDecoderStreamText can appear as soon as chunks arrive.
File hashStream into an incremental hasherThe 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.body can be null, 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 Uint8Array bytes. 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 EXERCISES
Exercise 1 · Warm-upPredict byte progress

Run the percentages in your head before checking.

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 denominator

    Your UI wants to show a percentage. Which header gives the total byte count?

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

      Exercise 3 · PracticeBody used after reading

      Predict the two booleans. Try it in a browser console or 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 split text

        Which decoder option prevents split emoji bytes 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 · ChallengeChoose upload progress honestly

          A product manager asks for a progress bar while a large file uploads. Which browser API has the progress event?

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

            Quiz

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

              Choose an answer to see the explanation.

            2. Question 2 of 7What 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 7When can you show a reliable download percentage?

              Choose an answer to see the explanation.

            4. Question 4 of 7Which line releases partial multi-byte characters safely?

              Choose an answer to see the explanation.

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

              Choose an answer to see the explanation.

            6. Question 6 of 7Which API has real upload progress events in browsers?

              Choose an answer to see the explanation.

            7. Question 7 of 7How should code handle a missing Content-Length?

              Choose an answer to see the explanation.

            Key takeaways

            • response.body is a ReadableStream of Uint8Array chunks 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.
            • TextDecoder needs { stream: true } or TextDecoderStream for 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.

            CompleteFrontend Clear concepts. Working examples.