Search for a React upload progress bar and most of the results reach for fetch. They await the response, then set progress to 100. Somewhere in the middle a number appears, either faked on a timer or reporting the wrong direction entirely. The reason isn't sloppiness. The fetch API exposes no upload progress event at all, so there's nothing honest to read. The older API everyone moved away from is still the only one that answers the question.
TL;DR
fetch settles when response headers arrive and exposes nothing about request bytes leaving the machine. A progress bar built on it is animation, not measurement. XMLHttpRequest still has upload.onprogress, which is why it survives for this one job. A chunked uploader sidesteps the question entirely: each acknowledged chunk is a real checkpoint, so onProgress reports confirmed bytes rather than buffered ones. That gap matters most on a slow connection, where the send buffer fills long before the bytes actually land.
What fetch reports, and what it does not
fetch returns a promise that settles when the response headers arrive. Between the call and that moment, the API surfaces nothing about how many request bytes have left the machine.
You can read progress on the way back, because response.body is a ReadableStream and you can count chunks as they arrive. That's download progress, which is useful for large file downloads and irrelevant to an upload. Some tutorials wire it up anyway, which is where the progress bar that only moves after the upload finishes comes from.
Chromium browsers do accept a ReadableStream as a request body when the request passes duplex: "half", which lets you count bytes as you enqueue them. It isn't available across browsers, it requires HTTP/2, and it counts bytes handed to the browser rather than bytes the server acknowledged. As a portable progress mechanism it isn't ready.
| API | Upload progress | Download progress | Portable today |
|---|---|---|---|
| fetch | No | Yes, via response.body | Yes |
| fetch with a streamed request body | Partial, bytes enqueued | Yes | No |
| XMLHttpRequest | Yes, via upload.onprogress | Yes | Yes |
| Chunked upload SDK | Yes, per completed chunk | Not applicable | Yes |
What you need before you start
- A React 18 or later app. The hook below is plain React with no dependencies.
- An upload endpoint to POST to. For the chunked section, a signed URL from your server.
- A FastPix account with an Access Token ID and Secret Key from activate your account, for the video half.
Step 1: XMLHttpRequest still owns upload progress
XMLHttpRequest has a separate upload object. It fires progress events with loaded and total byte counts as the request body goes out:
function upload(file, url, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
onProgress((event.loaded / event.total) * 100);
}
});
xhr.addEventListener("load", () => resolve(xhr.response));
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
xhr.open("POST", url);
xhr.send(file);
});
}Check event.lengthComputable before dividing. It's false when the total size is unknown. Skip the check and you get NaN percent, which React renders as a blank bar with no error.
Step 2: Wrap it in a hook so components stay simple
The component shouldn't know about XHR. A hook gives you progress, an error and a cancel function. It keeps the request object in a ref, so re-renders don't lose it:
import { useCallback, useRef, useState } from "react";
export function useUpload() {
const xhrRef = useRef(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState(null);
const start = useCallback((file, url) => {
setProgress(0);
setError(null);
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) setProgress((event.loaded / event.total) * 100);
};
xhr.onerror = () => setError("Upload failed");
xhr.open("POST", url);
xhr.send(file);
}, []);
const cancel = useCallback(() => xhrRef.current?.abort(), []);
return { progress, error, start, cancel };
}You can run this against a real upload before committing to it. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.
Store the XHR in a ref rather than state. Putting it in state triggers a re-render on every upload start and gives you a stale reference inside callbacks captured at the previous render.
Step 3: The last percent is the one that lies
An upload progress bar that reaches 100 percent and then sits there isn't broken. It's reporting exactly what it was asked to report, which isn't what the user thinks it means.
upload.onprogress counts bytes handed to the operating system's network stack, so the counter completes when the last byte is written to a socket. The server still has to receive the tail, finish reading the body, and respond. On a slow connection with a large file that gap is seconds, and on a server doing work before responding it's longer.
Two fixes, and the second is better. Cap the visible bar at 99 percent while bytes are in flight and only show 100 after the load event fires. Or stop describing the tail as progress at all. Switch the label to "Finishing up" once loaded equals total. That's honest, and it removes the frozen-bar complaint entirely.
Step 4: Chunked uploads change what progress means
Once an upload is split into chunks, progress stops being a socket-level guess. Each chunk is its own request with its own response. A completed chunk is a fact the server confirmed, not bytes pushed into a buffer.
That changes what you can build on top of it. Progress survives a pause. It survives the browser going offline and coming back, because the uploader resumes from the last confirmed chunk rather than restarting. And a failed chunk is visible as a distinct event instead of a stalled percentage.
import { Uploader } from "@fastpix/resumable-uploads";
const upload = Uploader.init({ endpoint: signedUrl, file, chunkSize: 5120 });
upload.on("progress", (e) => setProgress(e.detail.progress));
upload.on("chunkSuccess", (e) => setConfirmed(e.detail.chunkNumber));
upload.on("chunkAttemptFailure", (e) => setRetrying(e.detail.chunkAttempt));
upload.on("offline", () => setNote("Offline. Resuming when the connection returns."));
upload.on("online", () => setNote(null));chunkAttemptFailure is the event most progress UIs are missing. Without it a retrying upload looks identical to a dead one, and the user cancels something that was going to succeed. The full event list is in the resumable uploads guide, and the React component wraps the same events as callback props, documented in the uploader README.
Video has a second progress bar
For every other file type, the upload finishing is the end. For video it's the halfway point, and a progress bar that stops at "Upload complete" sets up the next support ticket.
After the bytes land, the file still has to be probed, transcoded into a ladder of renditions, segmented and given a manifest. Only then can a browser play it. So onSuccess means delivered, not playable, and a user who clicks play at that moment gets an error.
The honest interface has two states rather than one percentage. The first is the upload, which you can report precisely because you control the bytes. The second is processing. You can't report it as a percentage, because you don't control the encoder queue. It resolves when the video.media.ready webhook arrives with the playback ID. Add video upload and playback to React (Vite) has the webhook handler, and automating video uploads with webhooks covers the wider event flow.
Build an honest upload progress bar with FastPix
Install @fastpix/fp-react-uploader, pass onProgress and onChunkAttemptFailure, and render FastPixTrack for the bar. Progress then comes from confirmed chunks rather than socket writes, so it survives a pause and an offline period without resetting. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. That's enough to pull your own network cable mid-upload and watch the bar pick up where it stopped.
Frequently Asked Questions (FAQs)
How do I show upload progress in React?
Use XMLHttpRequest and listen for progress events on its upload object, which report loaded and total byte counts. Check event.lengthComputable before dividing, since the total is not always known. Wrap the whole thing in a custom hook so components receive a percentage and a cancel function rather than a request object.
Why does fetch not support upload progress?
Because the Fetch API exposes no event between sending a request and receiving response headers. response.body gives you a readable stream for the download direction only. Chromium browsers accept a streamed request body with duplex: "half", which counts bytes as they are enqueued. It is not portable across browsers, and it requires HTTP/2.
Why does my upload progress bar reach 100% and then hang?
Because upload.onprogress counts bytes written to the network stack, not bytes the server has processed. The counter finishes when the last byte hits the socket, and the server still has to read the tail and respond. Cap the bar at 99 percent until the load event fires, or change the label to "Finishing up" instead.
Should I use XMLHttpRequest or fetch for file uploads in React?
Use XMLHttpRequest if you need a progress bar. It is the only portable API that reports upload progress. Use fetch for everything else, including the request that creates a signed upload URL. For large files, a chunked upload SDK gives you both progress and resumability, and internally handles the request mechanics either way.
How do I track progress of a chunked upload?
Listen to the uploader's progress event for the overall percentage, and to chunkSuccess for server-confirmed chunks. Chunk-level progress is more trustworthy than socket-level progress because each completed chunk has a response behind it. Also handle chunkAttemptFailure, or a retrying upload will look identical to a stalled one.





