Fetch: Download Progress
The fetch API doesn’t have a built-in progress event, but you can track download progress by reading the response as a stream.
The Problem
XMLHttpRequest has onprogress for both upload and download:
const xhr = new XMLHttpRequest();
xhr.onprogress = function(e) {
console.log(e.loaded / e.total * 100 + '%');
};
fetch returns the full response at once — no progress. But we can use response.body.getReader() to read chunks as they arrive.
Tracking Download Progress
const response = await fetch(url);
const reader = response.body.getReader();
const contentLength = +response.headers.get('Content-Length');
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
const percent = (received / contentLength * 100).toFixed(1);
console.log(percent + '%');
}
Demo: Download Progress
HTML
<div>
<button id='progress-btn'>Download with Progress</button>
<div style='margin-top:8px;height:20px;background:#e2e8f0;border-radius:10px;overflow:hidden;'>
<div id='progress-bar' style='height:100%;width:0%;background:#6366f1;border-radius:10px;transition:width 0.3s;'></div>
</div>
<pre id='progress-out' style='background:#f1f5f9;padding:12px;border-radius:6px;'></pre>
</div> JavaScript
const bar = document.getElementById('progress-bar');
const out = document.getElementById('progress-out');
document.getElementById('progress-btn').onclick = async function() {
try {
this.disabled = true;
this.textContent = 'Downloading...';
bar.style.width = '0%';
out.textContent = 'Starting download...\\n';
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const contentLength = +response.headers.get('Content-Length') || 0;
const reader = response.body.getReader();
let received = 0;
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
out.textContent += 'Download complete!\\n';
break;
}
chunks.push(value);
received += value.length;
if (contentLength) {
const percent = (received / contentLength * 100).toFixed(1);
bar.style.width = percent + '%';
out.textContent = 'Downloaded: ' + (received / 1024).toFixed(1) + ' KB (' + percent + '%)';
} else {
out.textContent = 'Downloaded: ' + (received / 1024).toFixed(1) + ' KB (unknown total)';
}
}
// Reassemble chunks
const allBytes = new Uint8Array(received);
let pos = 0;
for (const chunk of chunks) {
allBytes.set(chunk, pos);
pos += chunk.length;
}
const text = new TextDecoder().decode(allBytes);
const data = JSON.parse(text);
out.textContent += 'Fetched ' + data.length + ' items (' + received + ' bytes total)';
bar.style.width = '100%';
} catch (err) {
out.textContent = 'Error: ' + err.message;
} finally {
this.disabled = false;
this.textContent = 'Download with Progress';
}
}; Live Output Window
How It Works
fetch(url)
│
▼
response.body.getReader()
│
▼
while loop:
├── reader.read() → { value: Uint8Array, done: boolean }
├── accumulate chunks
├── update progress bar
└── until done
│
▼
reassemble chunks into complete data
No Content-Length
If the server doesn’t send Content-Length, you can’t compute a percentage. Show indeterminate progress instead:
const hasLength = response.headers.has('Content-Length');
if (hasLength) {
// show percentage
} else {
// show spinner or "Downloading..."
}
Using TextDecoder with Streams
For text responses, decode as you go:
const decoder = new TextDecoder();
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
console.log('Received chunk:', text);
}
Progress for Upload
fetch doesn’t support upload progress at all. For upload progress, use XMLHttpRequest:
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = function(e) {
const percent = (e.loaded / e.total) * 100;
};
xhr.open('POST', url);
xhr.send(formData);
Key Takeaways
- Use
response.body.getReader()for streaming fetch progress - Read chunks in a
whileloop — accumulate, update UI, repeat - Compute percentage from
Content-Lengthheader when available - Reassemble chunks with
TextDecoderor by concatenatingUint8Array - For upload progress, use
XMLHttpRequestinstead of fetch - Streaming is also useful for large JSON responses — process data as it arrives