How to Track Download Progress with the Fetch API in JavaScript
Introduction
When downloading large files, data sets, or media through fetch(), users stare at a blank screen with no idea whether the request is 5% done or 95% done. Unlike the older XMLHttpRequest, which provided a simple progress event, fetch() does not offer a built-in progress callback. This was a deliberate design choice: the Fetch API uses streams as its underlying mechanism for reading response bodies, and progress tracking is built on top of that streaming architecture.
The key to tracking download progress with fetch() lies in the response.body property, which is a ReadableStream. Instead of waiting for the entire response to download and then receiving it all at once (which is what response.json() or response.text() do internally), you can read the stream chunk by chunk, tracking how many bytes have arrived so far compared to the total expected size.
In this guide, you will learn how ReadableStream works, how to read a response body incrementally, how to calculate download progress, and how to build practical progress indicators for real-world applications.
Why fetch() Has No Built-In Progress Event
With the older XMLHttpRequest, tracking progress was straightforward:
// The old XHR way (simple but outdated)
const xhr = new XMLHttpRequest();
xhr.open('GET', '/large-file.zip');
xhr.onprogress = function(event) {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
console.log(`${percent.toFixed(1)}% downloaded`);
}
};
xhr.onload = function() {
console.log('Download complete');
};
xhr.send();
The Fetch API replaced this event-based model with streams. Streams are more powerful and flexible because they let you process data as it arrives, transform it on the fly, pipe it to other destinations, and cancel reading at any point. The trade-off is that progress tracking requires a bit more code.
This article covers download progress (tracking data arriving from the server). Upload progress with fetch() is a different matter entirely. As of now, the Fetch API does not support upload progress tracking natively. For upload progress, you still need XMLHttpRequest or the newer fetch() upload streaming proposals.
Understanding response.body as a ReadableStream
When you call fetch(), the returned Response object has a body property that is a ReadableStream. This stream delivers the response data in small pieces called chunks as they arrive from the network.
What Is a ReadableStream?
A ReadableStream represents a source of data that you can read from sequentially. Think of it like a water pipe: data flows through it continuously, and you can catch it bucket by bucket (chunk by chunk) rather than waiting for the entire reservoir to fill.
const response = await fetch('https://example.com/large-file.json');
console.log(response.body);
// ReadableStream { locked: false }
console.log(response.body instanceof ReadableStream);
// true
The Reader Pattern
To read from a ReadableStream, you obtain a reader using the getReader() method. The reader provides a read() method that returns a Promise resolving to an object with two properties:
value: AUint8Arraycontaining the bytes of the current chunkdone: A boolean indicating whether the stream has ended
const response = await fetch('https://example.com/data.json');
const reader = response.body.getReader();
// Read one chunk
const { value, done } = await reader.read();
console.log(done); // false (there's probably more data)
console.log(value); // Uint8Array(16384) [ 123, 34, 110, 97, ... ]
Reading the Entire Stream Chunk by Chunk
To read the complete response, you call read() in a loop until done is true:
const response = await fetch('https://example.com/data.json');
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
chunks.push(value);
console.log(`Received chunk: ${value.length} bytes`);
}
console.log('Stream complete');
Output (example):
Received chunk: 16384 bytes
Received chunk: 16384 bytes
Received chunk: 16384 bytes
Received chunk: 8192 bytes
Stream complete
Each chunk is a Uint8Array (a typed array of bytes). The size of each chunk is determined by the browser and the network conditions. You have no control over chunk sizes, and they can vary significantly between reads.
Tracking Download Progress
To show meaningful progress, you need two pieces of information:
- How many bytes have been received so far (you count this as chunks arrive)
- The total size of the response (from the
Content-Lengthheader)