Fetch: Download Progress · Astro Tech Blog

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 while loop — accumulate, update UI, repeat
  • Compute percentage from Content-Length header when available
  • Reassemble chunks with TextDecoder or by concatenating Uint8Array
  • For upload progress, use XMLHttpRequest instead of fetch
  • Streaming is also useful for large JSON responses — process data as it arrives