Teams that standardise on fetch discover the gap the first time they build an upload: fetch gives no upload progress events, so the progress bar sits at 0% for the whole upload and jumps to 100% — and with no progress, users cancel and retry uploads that were nearly done.
In the file upload fields lifecycle, the uploading state carries loaded and total and an abort function. This page implements that state with XMLHttpRequest, which has reported upload progress for over a decade, wrapped in a promise and wired to AbortSignal so it composes with the rest of your cancellation code.
Context and prerequisites
The state of the platform:
XMLHttpRequestexposesxhr.upload.onprogresswithloadedandtotalbytes, plusonload,onerror,ontimeoutandabort(). It is supported everywhere.fetchcan download with progress by reading the response body stream, but it has no upload progress event. Streaming request bodies (body: ReadableStreamwithduplex: "half") are available in Chromium over HTTP/2 or later, and counting bytes as the stream is read gives an approximation — but support is not universal and it does not account for bytes buffered by the network stack.- Progress is measured on the client side of the connection. 100% means the browser has handed all bytes to the network; the server may still be receiving, scanning or storing. Show a separate “Processing…” phase between 100% and the response.
So: use XHR for uploads that need progress, fetch for everything else, and hide the difference behind one helper.
The core pattern: a promise-based XHR upload with AbortSignal
export interface UploadProgress { loaded: number; total: number }
export interface UploadOptions {
url: string;
file: File;
fieldName?: string;
headers?: Record<string, string>;
signal?: AbortSignal; // compose with the rest of your cancellation
timeoutMs?: number; // 0 = no timeout (large files on slow links)
onProgress?: (p: UploadProgress) => void;
onSent?: () => void; // all bytes handed to the network: show "processing"
}
export class UploadError extends Error {
constructor(message: string, readonly kind: "network" | "timeout" | "aborted" | "http", readonly status = 0) {
super(message);
}
}
export function uploadFile<T = unknown>(opts: UploadOptions): Promise<T> {
return new Promise<T>((resolve, reject) => {
// Fail fast if the caller's signal is already aborted.
if (opts.signal?.aborted) return reject(new UploadError("aborted", "aborted"));
const xhr = new XMLHttpRequest();
xhr.open("POST", opts.url);
xhr.responseType = "json";
xhr.timeout = opts.timeoutMs ?? 0;
for (const [k, v] of Object.entries(opts.headers ?? {})) xhr.setRequestHeader(k, v);
// Progress listeners must be attached BEFORE send(); attaching an upload
// listener also makes the request "non-simple", which triggers a CORS
// preflight for cross-origin uploads.
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) opts.onProgress?.({ loaded: e.loaded, total: e.total });
};
xhr.upload.onload = () => opts.onSent?.();
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as T);
else reject(new UploadError(`HTTP ${xhr.status}`, "http", xhr.status));
};
xhr.onerror = () => reject(new UploadError("network error", "network"));
xhr.ontimeout = () => reject(new UploadError("timed out", "timeout"));
xhr.onabort = () => reject(new UploadError("aborted", "aborted"));
// Bridge AbortSignal → xhr.abort(). Remove the listener when settled so a
// long-lived signal (e.g. the form's unmount signal) does not accumulate them.
const onAbort = () => xhr.abort();
opts.signal?.addEventListener("abort", onAbort, { once: true });
xhr.onloadend = () => opts.signal?.removeEventListener("abort", onAbort);
const body = new FormData();
body.append(opts.fieldName ?? "file", opts.file, opts.file.name);
xhr.send(body); // browser sets the multipart boundary; do not set Content-Type
});
}
Step-by-step walkthrough
- Create one
AbortControllerper file item. Store itsabortin the item’suploadingstate so the Cancel button and the Remove button both cancel the request. - Attach progress listeners before
send(). Listeners added after sending may miss early events, and attaching an upload listener changes CORS behaviour — plan for a preflight on cross-origin endpoints. - Report progress through state, throttled. Progress events can fire many times per second; update the item at most every 100 ms or on whole-percent changes to avoid re-rendering the form constantly.
- Show a processing phase after
upload.onload. All bytes are sent; the server is still working. “Processing…” is honest where a bar stuck at 100% is not. - Classify failures.
abortedis the user’s choice — remove the item quietly.networkandtimeoutgo tofailedwith Retry.http413 maps to the size message from validating file type and size before upload; 4xx is a rejection; 5xx is retryable, as in retrying failed submissions with backoff. - Expose progress accessibly. A
<progress>element with a label naming the file, and polite announcements only at start, completion and failure.
<li data-file-id="f1" data-file-state="uploading">
<span id="f1-name">site-plan.pdf</span>
<progress id="f1-progress" max="100" value="42" aria-labelledby="f1-name f1-progress-label"></progress>
<span id="f1-progress-label">42% uploaded</span>
<button type="button">Cancel upload<span class="visually-hidden"> of site-plan.pdf</span></button>
</li>
Failure modes and edge cases
1. Setting Content-Type manually for FormData
xhr.setRequestHeader("Content-Type", "multipart/form-data") omits the boundary parameter, and the server cannot parse the body. Let the browser set it from the FormData.
2. Timeouts that kill slow but healthy uploads
A 30-second timeout on a 200 MB upload fails on any connection slower than about 50 Mbit/s. Either disable the XHR timeout for large files and detect stalls instead (no progress event for 30 seconds → abort and mark failed), or use resumable chunked uploads so a timeout loses only one chunk.
3. lengthComputable false
Some proxies and configurations report progress without a total. Show an indeterminate progress bar (a <progress> with no value) plus bytes sent, rather than a bar stuck at 0%.
4. Re-renders from progress
Updating React or Vue state on every progress event re-renders the file list dozens of times per second. Throttle, and keep progress in a store that only the progress bar subscribes to — the isolation approach from memoization boundaries for form fields.
5. Aborting does not delete the server copy
If the abort happens after the server has stored the file, the upload may exist server-side with no reference. The server’s orphan cleanup handles it; the client simply forgets the item.
Verification checklist
Frequently Asked Questions
Will fetch ever support upload progress?
It has been discussed for years in the Fetch standard, and streaming request bodies are a partial step. Until a progress mechanism is broadly available, XHR is the dependable choice. Wrapping it behind a helper like uploadFile means you can switch implementations later without touching form code.
Is Axios's onUploadProgress different?
In browsers, Axios uses XMLHttpRequest under the hood, and onUploadProgress is a wrapper over xhr.upload.onprogress. The same caveats apply: progress measures bytes handed to the network, not bytes stored by the server.
Should uploads run in parallel?
Two or three at a time is a good default; browsers limit connections per origin under HTTP/1.1 and a dozen parallel uploads compete for bandwidth so each shows slow progress. Queue the rest, and show them as “Waiting” so the user understands why they have not started.