Fetch: Download progress
When you download something big, users want to see a progress bar move. fetch can give you that for downloads — the data coming from the server to the browser.
One limit up front: fetch cannot report upload progress, the bytes going from the browser to the server. There’s no hook for it. When you need an upload bar (a file picker sending a large video, say), reach for XMLHttpRequest instead, which exposes upload events. We cover that separately.
Why the normal methods can’t do it
You usually finish a fetch like this:
let response = await fetch(url);
let data = await response.json();
response.json() waits for the entire body to arrive, then hands you a parsed object. It’s all-or-nothing. There’s no moment in the middle where you can ask “how much do we have so far?” The same goes for response.text(), response.blob(), and friends. They’re convenient precisely because they hide the streaming detail from you.
To measure progress you need the opposite: access to the body as it flows in, piece by piece. That’s what response.body gives you.
response.body is a stream
response.body is a ReadableStream — an object that delivers the body one chunk at a time as it comes off the network. It’s defined by the Streams API. Instead of a single blob of “everything,” you get a sequence of small byte arrays, and you decide what to do with each one.
ReadableStream
Reading the stream
Get a reader from the stream and pull chunks in a loop:
// instead of response.json() or another body method
const reader = response.body.getReader();
// keep reading while the body is still downloading
while (true) {
// done is true once the last chunk has arrived
// value is a Uint8Array holding this chunk's bytes
const { done, value } = await reader.read();
if (done) {
break;
}
console.log(`Received ${value.length} bytes`);
}
Every await reader.read() resolves to an object with two fields:
done—falsewhile more data is coming,trueonce the stream is finished.value— aUint8Arrayof bytes for this chunk. On the final read wheredoneistrue,valueisundefined, which is why the code breaks out before touching it.
The loop looks infinite, but it isn’t: each iteration awaits the next chunk, and when the body ends you get done: true and break.
The full example, with progress
Counting bytes is the whole trick: keep a running total, and add each chunk’s length to it. Here’s a complete download that logs progress and then rebuilds the body into usable data.
// Step 1: start the fetch and grab a reader
let response = await fetch('https://data.example.org/api/stations/readings?limit=500');
const reader = response.body.getReader();
// Step 2: read the total length from the header (may be missing)
const contentLength = +response.headers.get('Content-Length');
// Step 3: read the stream, chunk by chunk
let receivedLength = 0; // how many bytes we've read so far
let chunks = []; // the binary chunks that make up the body
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
chunks.push(value);
receivedLength += value.length;
console.log(`Received ${receivedLength} of ${contentLength}`);
}
// Step 4: stitch the chunks into one Uint8Array
let chunksAll = new Uint8Array(receivedLength); // (4.1)
let position = 0;
for (let chunk of chunks) {
chunksAll.set(chunk, position); // (4.2)
position += chunk.length;
}
// Step 5: decode the bytes into a string
let result = new TextDecoder("utf-8").decode(chunksAll);
// Done — parse it if it's JSON
let readings = JSON.parse(result);
alert(readings[0].station.name);
The sandbox here has no network, so we can’t hit a real server — but we can build a ReadableStream by hand that hands out chunks over time, then drive it with the exact same getReader() / while loop. Watch receivedLength climb toward the known total and move the bar. Press “Start download” to run it again.
Walking through it:
-
Start the fetch, take a reader. Everything is normal up to
response.body.getReader(). From here on, you drive the reading instead of a body method.You get one shot at the body. Once you read it through the stream, calling
response.json()orresponse.text()on the same response throws — the body is already consumed. Pick the reader or a convenience method, not both. -
Find the total size. The
Content-Lengthheader tells you how many bytes to expect, which is what a progress bar needs for its denominator.+response.headers.get(...)converts the header string to a number.Two caveats worth respecting: the header can be absent on cross-origin responses (see Fetch: Cross-Origin Requests), and strictly speaking a server isn’t required to send it. In practice it’s usually there, but write your UI so it degrades gracefully when it isn’t.
-
Read until done. Each chunk gets pushed into
chunksand its length added toreceivedLength. Keeping every chunk matters: after the stream is drained there’s no way to ask the response for its body again, so if you throw the chunks away you’ve lost the data. -
Join the chunks. You end up with an array of
Uint8Arraypieces and need one contiguous array. There’s no built-in “concatenate these typed arrays” call, so you do it by hand:new Uint8Array(receivedLength)allocates one buffer big enough for everything..set(chunk, position)copies each chunk in at the right offset, andpositionadvances so the next chunk lands right after the previous one.
-
Turn bytes into text.
chunksAllis raw bytes, not a string.TextDecoderinterprets those bytes as UTF-8 and produces a string, which you can thenJSON.parseif the response is JSON.
Steps 4 and 5 are pure, network-free byte work, so you can run them right here. Type a message, and the demo encodes it to bytes, slices those bytes into small Uint8Array chunks (pretending they arrived separately), then stitches them back with .set(...) and decodes with TextDecoder. Try an emoji or an accented letter — those take more than one byte, so the chunk count jumps.
When you want binary, not text
If the body isn’t text — an image, a zip, a PDF — you don’t want TextDecoder at all. Decoding arbitrary bytes as UTF-8 would corrupt them. Skip steps 4 and 5 and hand the chunks straight to a Blob, which happily accepts an array of Uint8Array pieces and concatenates them for you:
let blob = new Blob(chunks);
Now you have a Blob you can turn into an object URL, put in an <img>, or save. Same progress tracking, no text conversion.
Guarding against unknown, huge downloads
When Content-Length is missing you don’t know how big the response is, and a hostile or buggy endpoint could stream forever. Since you’re buffering every chunk in chunks, an unbounded body means unbounded memory. Cap it: watch receivedLength inside the loop and bail out once it crosses a limit you’re comfortable with.
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB ceiling
while (true) {
const { done, value } = await reader.read();
if (done) break;
receivedLength += value.length;
if (receivedLength > MAX_BYTES) {
await reader.cancel(); // stop the download
throw new Error('Response too large');
}
chunks.push(value);
}
reader.cancel() tells the stream you’re finished so the browser can stop pulling data.