Fetch Upload Progress vs XMLHttpRequest
fetch() exposes no upload progress events, so choose by situation: use XMLHttpRequest and xhr.upload.onprogress when you need a byte-accurate bar for a single-request upload in every browser; use a ReadableStream body piped through a counting TransformStream with duplex: "half" where streaming uploads are supported; or split the file into chunks and count completed chunks with plain fetch, which works everywhere and is what resumable uploads do anyway.
Progress is the one upload feature where the older API is still better. fetch gives you promises, AbortSignal, streaming responses and a clean API — and then leaves you with no way to know how much of a 400 MB request body has left the browser. Teams that migrate everything to fetch discover this when the progress bar jumps from 0% to 100%. This page is part of modern fetch API for uploads in upload fundamentals and browser APIs. The migration itself is covered in migrating XHR upload code to fetch.
When to use each approach
- XHR when uploads are single requests (a form post, a presigned PUT) and the bar must be accurate in every browser, including Safari and Firefox.
- Streaming fetch when you already stream bodies for other reasons (compression, hashing on the fly) and your users are on Chromium browsers talking to an HTTP/2 or HTTP/3 endpoint.
- Chunked fetch when files are large, uploads must resume, or you want one code path everywhere — progress then comes for free from chunk completion.
Prerequisites
- TypeScript 5 with DOM types.
- For streaming uploads: a Chromium-based browser (105+) and an endpoint served over HTTP/2 or HTTP/3; Firefox and Safari do not support streaming request bodies at the time of writing.
- For chunked uploads: an endpoint that accepts byte ranges or parts — tus, S3 multipart with presigned part URLs, or your own offset protocol.
What each approach actually measures
Implementation
All three behind one signature, so the UI does not care which is used:
export type Progress = (sent: number, total: number) => void;
/** 1. XMLHttpRequest: accurate everywhere, one request. */
export function uploadWithXhr(url: string, body: Blob, onProgress: Progress, signal?: AbortSignal): Promise<number> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", body.type || "application/octet-stream");
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); };
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve(xhr.status) : reject(new Error(`HTTP ${xhr.status}`)));
xhr.onerror = () => reject(new TypeError("network error"));
xhr.onabort = () => reject(new DOMException("Aborted", "AbortError"));
signal?.addEventListener("abort", () => xhr.abort(), { once: true });
xhr.send(body);
});
}
/** 2. Streaming fetch: counts bytes as fetch pulls them from the stream. */
export async function uploadWithStream(url: string, body: Blob, onProgress: Progress, signal?: AbortSignal): Promise<number> {
let sent = 0;
const counter = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
sent += chunk.byteLength;
onProgress(sent, body.size);
controller.enqueue(chunk);
},
});
const res = await fetch(url, {
method: "PUT",
body: body.stream().pipeThrough(counter),
headers: { "Content-Type": body.type || "application/octet-stream" },
duplex: "half", // required for stream bodies
signal,
} as RequestInit & { duplex: "half" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.status;
}
/** 3. Chunked fetch: progress = bytes the server confirmed. Works everywhere. */
export async function uploadInChunks(
urlForChunk: (index: number) => Promise<string>,
body: Blob,
onProgress: Progress,
chunkSize = 8 * 1024 * 1024,
signal?: AbortSignal,
): Promise<number> {
const count = Math.ceil(body.size / chunkSize);
let done = 0;
for (let i = 0; i < count; i++) {
const part = body.slice(i * chunkSize, Math.min((i + 1) * chunkSize, body.size));
const res = await fetch(await urlForChunk(i), { method: "PUT", body: part, signal });
if (!res.ok) throw new Error(`chunk ${i}: HTTP ${res.status}`);
done += part.size;
onProgress(done, body.size);
}
return 200;
}
/** Pick the best available method at runtime. */
export function supportsStreamingUpload(): boolean {
let duplexAccessed = false;
const hasContentType = new Request("https://example.invalid", {
body: new ReadableStream(),
method: "POST",
get duplex() { duplexAccessed = true; return "half"; },
} as RequestInit).headers.has("Content-Type");
return duplexAccessed && !hasContentType;
}
Line-by-line on the details that matter
e.lengthComputable. XHR knows the total when the body is aBlob,File,ArrayBufferorFormData. It is false only for unusual bodies; checking it avoids dividing by zero.- XHR progress includes multipart overhead. For a
FormDatabody,e.totalis the whole encoded body (boundaries, headers, all files), notfile.size. Usee.totalfor the bar, not your own file size. - The
TransformStreamcounts bytes read, not bytes sent.fetchpulls from the stream into its own buffers ahead of the network, so this counter reaches 100% a little before the upload finishes — by roughly the socket buffer size. Keep the bar at 99% until the response arrives. duplex: "half"is mandatory for stream bodies; omitting it throwsTypeError: The duplex member must be specified for a request with a streaming body. Chromium also refuses streaming bodies over HTTP/1.1 withERR_H2_OR_QUIC_REQUIRED.supportsStreamingUpload()is the standard feature test: a browser that supports streaming reads theduplexgetter and does not add aContent-Typefor a stream body. It needs no network request.- Chunked progress is stepwise. With 8 MB chunks, a 100 MB file advances in 8% steps. That is honest — each step is data the server confirmed — and smaller chunks smooth it at the cost of more requests.
How the bars differ in practice
Configuration gotchas
xhr.upload.onprogress never fires. Listeners attached after send() miss early events, and in some browsers attaching an upload listener after send() has no effect. Attach every upload listener before calling send(). Also note that attaching one turns a simple CORS request into a preflighted one.
TypeError: Failed to fetch with a stream body on Safari or Firefox. Streaming request bodies are not supported there. Feature-detect and fall back to XHR or chunks.
net::ERR_H2_OR_QUIC_REQUIRED. Chromium refuses streaming uploads over HTTP/1.1. Local development servers are often HTTP/1.1 only; test streaming against the real HTTP/2 endpoint.
Progress hits 100% and then waits for seconds. The server is processing (virus scan, thumbnailing) before responding. Switch the label from “Uploading” to “Processing” when progress reaches the end, and show the processing state from the server as in notifying clients when processing finishes.
Choosing by what else you need
Why fetch still has no progress events
The Fetch standard has discussed upload progress for years. The difficulty is less the API than the definition: with HTTP/2 multiplexing, HTTP/3 over QUIC, and request bodies that are streams of unknown length, “bytes uploaded” can mean bytes read from the body, bytes handed to the connection, or bytes acknowledged by the peer — and those differ by megabytes on a fast link with large buffers. XHR’s progress events predate that complexity and report something close to “handed to the socket”.
For application code the practical consequence is that there is no reason to wait. XHR is not deprecated, is supported everywhere, and composes fine with the rest of a fetch-based codebase if you wrap it in a promise as above. Streaming bodies give you a counter today in Chromium. And chunked uploads give you the one number that matters for resumption — the server’s — in every browser. Pick by the other requirements, and the progress bar comes along.
Verification
// Throttle DevTools to "Fast 3G", then:
const file = new File([new Uint8Array(20 * 1024 * 1024)], "test.bin");
const seen: number[] = [];
await uploadWithXhr("/api/echo", file, (s, t) => seen.push(Math.round((100 * s) / t)));
console.log("xhr events:", seen.length, "first", seen[0], "last", seen.at(-1));
// xhr events: 60+ first 0–2 last 100
if (supportsStreamingUpload()) {
const s2: number[] = [];
await uploadWithStream("/api/echo", file, (s, t) => s2.push(Math.round((100 * s) / t)));
console.log("stream events:", s2.length);
}
A healthy XHR upload reports dozens of events on a throttled link; a single event at 100% means the listener was attached too late or the body type is not measurable.
Frequently Asked Questions
Is XMLHttpRequest deprecated?
No. Synchronous XHR on the main thread is deprecated; asynchronous XHR is a fully supported web platform API with no removal planned. Using it for uploads that need progress is ordinary, current practice.
Can a service worker see upload progress?
Not for requests it forwards. A service worker handling a fetch event receives the request body as a stream it can read, which would let it count bytes as it re-sends them, but that adds a copy and complexity for little gain. Measure in the page instead.
How often should the UI update from progress events?
XHR can fire progress events dozens of times per second on a fast link, and a counting stream fires once per chunk read. Rendering on every event wastes main-thread time; store the latest numbers and render them in a requestAnimationFrame callback, at most once per frame, and throttle any text such as “12.4 MB of 80 MB” to a few updates per second so it stays readable.
Does Axios or ky give fetch progress?
Axios uses XHR in the browser, so its onUploadProgress is XHR’s event. Libraries built purely on fetch either lack upload progress or implement it with streaming bodies, inheriting the browser support limits above.