Your upload widget must show a live progress bar while a file is being sent to the server. Why does that still mean XMLHttpRequest rather than fetch, and which object do you attach the progress listener to?
answer
- two directions, two event targets
- the request has its own target
- ProgressEvent: loaded, total, lengthComputable
- bytes sent, not bytes processed
- the one thing fetch never got
basics
~20 sXMLHttpRequest exposes an upload object, and progress events fired on xhr.upload report bytes sent. As of 2026 fetch has no equivalent upload-progress event, which is why file-upload widgets that show a live bar are still written against XHR.
solid answer
~50 sYou attach the listener to `xhr.upload`, not to `xhr` itself. `xhr.upload` is a separate event target whose `progress` events describe the **request** body going out; `progress` events on `xhr` describe the **response** coming back. Each is a `ProgressEvent` carrying `lengthComputable`, `loaded` and `total`, so the bar is `e.loaded / e.total` when `lengthComputable` is true. This is the main reason XHR is still written by hand in 2026: `fetch` gives you a streaming response body, so download progress is achievable there, but it exposes no upload-progress callback at all. Streaming a request body with the `duplex: 'half'` init option is Chromium-only and would still leave you counting bytes yourself. One caveat to state out loud: the numbers are bytes handed to the network stack, so the bar can hit 100% while the server is still processing — the request is not done until the `load` event fires.
code
javascript · 19 linesfunction upload(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/files');
xhr.upload.addEventListener('progress', (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : null);
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
else reject(new Error('HTTP ' + xhr.status));
});
xhr.addEventListener('error', () => reject(new Error('network error')));
const body = new FormData();
body.append('file', file);
xhr.send(body);
});
}go deeper
Know that xhr.upload is the object to listen on for send progress, and that the event gives you loaded and total. Being able to write the two-line percentage calculation is enough at this level.
Explain the two-target model — xhr for the response, xhr.upload for the request — the ProgressEvent fields including lengthComputable, and state clearly that fetch offers no upload-progress equivalent.
Show production judgment: progress counts bytes written to the network, so design the UI for the 100%-then-processing gap, take the verdict from status in load, and anticipate the cross-origin preflight that an upload listener introduces.
Own the tradeoff of keeping one XHR-based transport in an otherwise fetch-based codebase versus adopting resumable or chunked upload protocols, weighing progress fidelity, retry granularity on flaky networks and the cost of a second networking layer to maintain.
## Two progress streams, two targets An HTTP exchange has two directions, and `XMLHttpRequest` models them with two event targets: - `xhr` fires `progress` for **response** bytes arriving (download). - `xhr.upload` fires `progress` for **request** bytes leaving (upload). `xhr.upload` is an `XMLHttpRequestUpload` object. It is created with the request and supports the same progress-event set as the request itself — `loadstart`, `progress`, `abort`, `error`, `load`, `timeout`, `loadend` — but scoped to the body you sent. Attaching to the wrong one is the classic bug: a bar wired to `xhr.addEventListener('progress', …)` on a POST sits at zero for the whole upload and then jumps, because it is measuring the small JSON response, not the 40 MB file. ```js const xhr = new XMLHttpRequest(); xhr.open('POST', '/files'); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) bar.value = e.loaded / e.total; }); xhr.addEventListener('load', () => done(xhr.status)); xhr.send(formData); ``` ## The ProgressEvent contract Every progress event is a `ProgressEvent` with three properties: - `lengthComputable` — whether a total is known. - `loaded` — bytes transferred so far. - `total` — the total to transfer, meaningful only when `lengthComputable` is `true`. For uploads, `lengthComputable` is normally `true`, because the browser knows the length of what you handed to `send()` — a `Blob`, a `File`, a `FormData`, an `ArrayBuffer` or a string all have a computable size. Always guard on it anyway: when it is `false`, `total` is `0` and `loaded / total` is `NaN`, which silently produces a broken bar rather than an error. The events are throttled by the browser; they are not one per packet. Do not build logic that assumes a final `progress` event with `loaded === total` — take completion from the `load` event on `xhr.upload` (all bytes sent) or from `load` on `xhr` (server responded). ## Why fetch does not cover this `fetch` returns a `Response` whose `body` is a `ReadableStream`, so counting **download** progress is possible by reading chunks. Upload is asymmetric: as of 2026 the Fetch standard defines no upload-progress event, and the `Request` body is consumed opaquely by the network layer. Streaming a request body is possible — you can pass a `ReadableStream` as `body` together with the init option `duplex: 'half'` — but that remains a Chromium-only capability that also requires HTTPS with HTTP/2 or later, and even then you would be counting the bytes you enqueue yourself rather than receiving progress from the browser. So the honest interview answer is: for a progress bar on an upload, XHR is not legacy nostalgia, it is the API that has the feature. Everything else in a modern codebase can be `fetch`. ## What the numbers actually mean `loaded` counts bytes the browser has written out toward the server. It does not mean the server has received, parsed or stored them. Two consequences show up in production: 1. **The 100%-then-wait gap.** On a fast local link, all bytes are buffered almost immediately, the bar completes, and the user then waits while the server writes to storage or transcodes. If the UI treats 100% as done, it lies. Show "processing" between upload completion and the `load` event. 2. **Progress is not delivery.** If the connection drops after the bytes were buffered, `error` fires even though the bar read 100%. The verdict is always `xhr.status` inside `load`. ## Practical details worth knowing - **Cancellation.** `xhr.abort()` stops an in-flight upload and fires `abort` on both `xhr` and `xhr.upload`, which is what a per-file cancel button calls. - **Cross-origin.** Registering **any** listener on `xhr.upload` makes the request non-simple in CORS terms, so the browser sends a preflight it would otherwise have skipped. A same-origin upload is unaffected; a cross-origin one now needs the server to answer the preflight. - **Multiple files.** Use one `XMLHttpRequest` per file when you want per-file bars and per-file cancellation; a single `FormData` with several files gives you one aggregate bar. - **Reading the file.** You do not need `FileReader` to upload a `File`; hand the `File` (or a `FormData` containing it) straight to `send()` and the browser streams it from disk. Reading it into memory first defeats that and can exhaust memory on large files.
- Why does attaching an upload progress listener change how a cross-origin request is sent?Registering any listener on `xhr.upload` sets the upload-listener flag, which disqualifies the request from the simple-request path, so the browser issues a CORS preflight first. The effect is that an upload that worked cross-origin without a progress bar can start failing when you add one, unless the server answers the preflight. Same-origin uploads are unaffected.
- The bar reaches 100% but the load event does not fire for another ten seconds. What is happening?`loaded` counts bytes the browser handed to the network stack, not work the server has finished. Once the body is buffered and sent, the request sits waiting for the response while the server writes, validates or transcodes. Treat 100% as "sent", switch the UI to a processing state, and take the real verdict from `xhr.status` inside the `load` handler.
- What does lengthComputable being false imply, and when does it happen on an upload?It means the browser has no total to divide by, so `total` is `0` and any percentage is `NaN`. On uploads it is rare, because a `File`, `Blob`, `FormData` or string has a known size; it is far more common on downloads, where a chunked response has no `Content-Length`. Always branch on the flag and fall back to an indeterminate bar.
saying these in an interview costs you the question
- Attaches the progress listener to xhr instead of xhr.upload
- Claims fetch has an upload progress callback
- Treats 100% progress as the upload having succeeded
- Divides loaded by total without checking lengthComputable
- Reads the whole File with FileReader before sending it