fetch has no upload progress event as of November 2026, so the dependable answer to fetch upload progress is to send the file with XMLHttpRequest and listen on xhr.upload. Counting bytes through a request stream worked only in Chromium when we tested it, and in no engine did 100% prove the server had the file.
Why does fetch have no upload progress event?
The machinery exists but JavaScript cannot reach it. The Fetch standard defines an internal processRequestBodyChunkLength callback, and the XHR standard uses it to fire its progress events. fetch() exposes nothing equivalent.
The proposal to change that, FetchObserver, has been open since September 2017. Anne van Kesteren, who writes the Fetch standard, said in 2022 that “As far as I know nobody is actively working on this”, and in November 2024 that the comment still applied. The comments since are requests and suggestions, with no implementation announced.
XMLHttpRequest remains a living WHATWG standard, so reaching for it here is a reasonable choice.
How do you track upload progress with XMLHttpRequest?
This wrapper gives you a promise, an AbortSignal and two callbacks:
export interface UploadOptions {
method?: 'POST' | 'PUT';
headers?: Record<string, string>;
signal?: AbortSignal;
onProgress?: (loaded: number, total: number | null) => void;
onSent?: () => void;
}
export interface UploadResult {
status: number;
body: string;
}
export function uploadWithProgress(
url: string,
body: XMLHttpRequestBodyInit,
{ method = 'POST', headers = {}, signal, onProgress, onSent }: UploadOptions = {},
): Promise<UploadResult> {
return new Promise((resolve, reject) => {
signal?.throwIfAborted();
const xhr = new XMLHttpRequest();
xhr.open(method, url);
for (const [name, value] of Object.entries(headers)) {
xhr.setRequestHeader(name, value);
}
// Must be registered before send(), or it never fires.
let sent = false;
xhr.upload.addEventListener('progress', (event) => {
onProgress?.(event.loaded, event.lengthComputable ? event.total : null);
if (!sent && event.lengthComputable && event.loaded === event.total) {
sent = true;
onSent?.();
}
});
const abort = () => xhr.abort();
signal?.addEventListener('abort', abort, { once: true });
xhr.addEventListener('loadend', () => signal?.removeEventListener('abort', abort));
xhr.addEventListener('load', () => resolve({ status: xhr.status, body: xhr.responseText }));
xhr.addEventListener('error', () => reject(new TypeError('Upload failed: network error')));
xhr.addEventListener('abort', () => {
reject(signal?.reason ?? new DOMException('Upload aborted', 'AbortError'));
});
xhr.send(body);
});
}
Like fetch, it resolves on a 500, so check status. Passing AbortSignal.timeout(ms) rejects with TimeoutError, the same pattern as our Node fetch timeout guide, though here the deadline covers the whole upload.
We ran the samples and browser tests in this post in the Playwright 1.61.1 builds of Chromium 149 (headless shell), Firefox 151 (a Nightly-branded binary) and WebKit 26.5, against a local Node 24.12 server on macOS. Those are not Chrome, Firefox or Safari as shipped, and we did not run Safari itself. We did not run Ky or Axios; those notes come from their readmes.
Four details came out of that run.
Divide by event.total, never file.size. A 1,048,576-byte file in FormData reported a total of 1,048,765 in Chromium and WebKit and 1,048,795 in Firefox, because the multipart wrapper counts too.
Register the listener before send(). Added afterwards, it received zero events in all three engines.
Cross-origin, the listener changes the request. The XHR standard says it plainly: “Registering one or more event listeners on an XMLHttpRequestUpload object will result in a CORS-preflight request.” The same text/plain POST sent one OPTIONS with a listener and none without, in all three. If you upload straight to a signed URL, like the targets in our stagedUploadsCreate walkthrough, that host has to answer the preflight. We did not test Shopify’s targets.
Do not use xhr.upload’s load event as “finished sending”. In Firefox 151 it fired when the response arrived, 3 seconds late against a server that waited 3 seconds to reply, on HTTP/1.1 and HTTP/2, with a bare XHR and no wrapper. The standard fires it at the end of the request body, and Chromium and WebKit did. That is why the wrapper watches for loaded === total, which all three reported no later than the response.
Can a request stream give fetch upload progress?
Only in Chromium. The technique is a TransformStream that counts bytes on the way past:
export function countBytes(
file: Blob,
onBytes: (read: number) => void,
): ReadableStream<Uint8Array> {
let read = 0;
const counter = new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
read += chunk.byteLength;
onBytes(read);
controller.enqueue(chunk);
},
});
return (file.stream() as ReadableStream<Uint8Array>).pipeThrough(counter);
}
export function streamUpload(url: string, file: Blob, onBytes: (read: number) => void) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: countBytes(file, onBytes),
duplex: 'half',
} as RequestInit);
}
The cast is there because TypeScript 5.8.2’s lib.dom rejects duplex with TS2353.
The Firefox result is the dangerous one. In that Nightly-branded build, the same call resolved with a 200, and the server received 23 bytes: the literal text [object ReadableStream]. Chrome’s guide describes this fallback, where a browser without support calls toString() on the body. The byte counter never ran. MDN’s compatibility data lists stream bodies for Firefox as preview only, and the tracking bug is still open. A file upload that reports success after sending that string is much worse than a missing progress bar.
WebKit at least fails loudly, rejecting with NotSupportedError: ReadableStream uploading is not supported. MDN lists no Safari support.
Chromium has its own limits, as Chrome’s own streaming guide warns. Over HTTP/1.1, with or without TLS, we got TypeError: Failed to fetch with net::ERR_ALPN_NEGOTIATION_FAILED in the console, and the server saw no request at all. A 307 rejected with net::ERR_INVALID_ARGUMENT. A 303 was followed as a GET with the body gone. The request carried no Content-Length, and any endpoint that insists on one will refuse it.
If you use streams anyway, gate them with Chrome’s detection snippet, which returned true in Chromium and false in Firefox and WebKit:
const supportsRequestStreams = (() => {
let duplexAccessed = false;
const hasContentType = new Request('', {
body: new ReadableStream(),
method: 'POST',
get duplex() {
duplexAccessed = true;
return 'half';
},
}).headers.has('Content-Type');
return duplexAccessed && !hasContentType;
})();
Then there is what the number means. It counts bytes read from your file, ahead of anything sent. Uploading 20 MiB to a server reading at 4 MiB/s over HTTP/2, the counter ran up to 2.26 MiB ahead of the server and fired only 13 times. XHR under the same conditions fired 48 or 49 times and stayed about 0.2 MiB ahead.
Ky’s onUploadProgress needs request streams and its readme says the handler is silently ignored where they are unsupported. Axios tries its xhr adapter first in a browser by default, so its progress events come from XHR unless you choose adapter: 'fetch'.
What does 100% actually mean?
That the browser has handed the bytes on. It does not mean the server has them, and how far apart those are depends on the protocol and the server’s settings. We sent 2 MiB to a server reading at 512 KiB/s, so every run took about 4.2 seconds. This is when each engine first reported 100%:
| Connection | Chromium 149 | Firefox 151 | WebKit 26.5 |
|---|---|---|---|
| HTTP/1.1 (3 runs) | 0.11 to 2.6 s | 2.4 to 2.5 s | under 0.02 s |
| HTTP/2, 64 KiB window (6 runs) | 3.7 to 3.9 s | 3.9 to 4.0 s | 3.8 to 4.0 s |
| HTTP/2, 16 MiB window (3 runs) | 0.10 to 0.11 s | 0.05 to 0.06 s | under 0.01 s |
The HTTP/2 rows are the same server with its flow-control window raised. A flow-control window is how much unread data a receiver will accept, and Node’s default initialWindowSize is 65,535 bytes. With that default the bar looked honest. With a 16 MiB window it was finished almost at once, when the server had 64 KiB or less, and on a 20 MiB upload the lead reached 14.3 to 15.8 MiB. So a good result on your local Node server tells you little about the proxy or CDN in front of production.
It is loopback on one Mac, with few runs and a deliberately slow reader as the bottleneck. HTTP/1.1 was noisy: Chromium ranged from 0.11 to 2.6 seconds, and an earlier round of the same test had Firefox at 1.2 to 1.6 seconds. We did not test a slow uplink, a real CDN or HTTP/3, and on those the size of the gap will differ. We would trust the direction of the result more than the figures.
Treat onSent as the moment to change the label from “Uploading” to “Processing”, and say “Saved” only when the promise resolves. With a server that replied after 2 seconds, onSent fired 1.97 to 2.68 seconds before the response across the three engines.
Redirects also move the bar. On a 307, XHR sends the whole body again. In one run per engine, Firefox and WebKit dropped loaded back near zero and climbed again, so a bar that assumes it only goes up will misbehave. Chromium reported 100% once, then nothing for the 5 seconds of the second send.
How do you show progress the server has confirmed?
Split the file and count a chunk only when the server has answered for it. This works with plain fetch in all three engines:
const CHUNK_SIZE = 5 * 1024 * 1024;
export async function uploadInChunks(
url: string,
file: Blob,
onConfirmed: (bytes: number) => void,
signal?: AbortSignal,
): Promise<void> {
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
const end = Math.min(start + CHUNK_SIZE, file.size);
const res = await fetch(`${url}?offset=${start}`, {
method: 'PUT',
body: file.slice(start, end),
signal,
});
if (!res.ok) throw new Error(`Chunk at ${start} failed with ${res.status}`);
onConfirmed(end);
}
}
The offset parameter is a contract we made up for this example, so your server has to implement it and reassemble the parts. The bar moves in 5 MiB steps, and a 500 stops the loop with Chunk at 0 failed with 500. There is no retry here either. If you need resuming after a dropped connection, use an existing protocol such as tus, where the client asks for the current Upload-Offset and carries on from there.
What would we use for fetch upload progress?
For an ordinary file upload with a progress bar, we would use the XHR wrapper above and keep fetch for everything else. It is one small function, and the events it depends on behaved the same way in all three engines.
We would move to confirmed chunks or tus for files large enough that a dropped connection costs real time, or when the number has to be true because something depends on it.
We would not build a request-stream progress bar today. The only strong case for a streamed body is one whose length you cannot know up front, and our CompressionStream guide buffers even that, for the same compatibility reasons. If Firefox and Safari ship stream bodies, the coverage objection falls away, but the bytes-read problem stays.
We build features like this as part of our web application development work, from the browser code to the back end that receives the file.