Tracking Upload Progress with a TransformStream
fetch has no upload progress event, but if you pass a ReadableStream as the request body you can insert a TransformStream in front of it whose transform() adds each chunk’s byteLength to a running total — that counter, divided by file.size, is your progress bar.
This article sits inside Streams API for uploads, part of upload fundamentals and browser APIs. It assumes you already know how to hand a stream to fetch — uploading with ReadableStream request bodies covers the request plumbing; this page is only about the measurement.
When to use this approach
- You are already streaming the request body — because you compress, encrypt or hash on the fly — and want progress without paying for a second pass over the bytes.
- Your users are on Chromium and your upload endpoint is HTTPS over HTTP/2 or HTTP/3, so the streaming request body is actually available.
- You need a progress bar but cannot switch to
XMLHttpRequest, for example because your request goes through afetchinterceptor, a Service Worker, or a library that only speaksRequest/Response.
If none of those hold, xhr.upload.onprogress is still the shorter, more portable answer — see the comparison further down and the wider treatment in real-time upload progress events.
Prerequisites
- A Chromium-based browser at version 105 or newer. Firefox and Safari still ship no streaming request bodies, so feature-detect and fall back.
- An HTTPS endpoint negotiating HTTP/2 or HTTP/3. Chrome refuses to stream a request body over HTTP/1.1.
- TypeScript with
lib: ["DOM", "ES2022"]. Theduplexinit member is not in the DOM typings yet, so you will cast theRequestInit. - An endpoint that accepts
Transfer-Encoding: chunked— a streamed body has noContent-Length.
Detect support before you commit to the code path. The check below works because a Request built with a stream body only reads the duplex getter when the engine actually supports streaming bodies:
export const supportsRequestStreams: boolean = (() => {
let duplexAccessed = false;
const req = new Request(location.href, {
method: "POST",
body: new ReadableStream(),
get duplex() {
duplexAccessed = true;
return "half";
},
} as RequestInit);
// Engines without streaming bodies stringify the stream and set a Content-Type.
return duplexAccessed && !req.headers.has("Content-Type");
})();
Implementation
One factory builds the counting transformer; one function wires it between file.stream() and fetch. Nothing copies the payload — the transformer forwards the identical Uint8Array it received.
export interface ProgressTick {
loaded: number; // bytes handed to the network stack
total: number; // file.size
ratio: number; // 0..1, clamped
}
/** A pass-through stream that counts bytes and reports at most once per frame. */
export function createProgressStream(
total: number,
onProgress: (tick: ProgressTick) => void,
): TransformStream<Uint8Array, Uint8Array> {
let loaded = 0;
let frame = 0; // pending rAF handle; 0 means nothing scheduled
let reported = -1;
const emit = () => {
reported = loaded;
onProgress({ loaded, total, ratio: total > 0 ? Math.min(loaded / total, 1) : 0 });
};
const flushFrame = () => {
frame = 0;
if (loaded !== reported) emit();
};
return new TransformStream<Uint8Array, Uint8Array>(
{
transform(chunk, controller) {
loaded += chunk.byteLength; // count before forwarding
controller.enqueue(chunk); // forward the same buffer — no copy
if (frame === 0) frame = requestAnimationFrame(flushFrame);
},
flush() {
// The writable side closed: every byte of the file has been read.
if (frame !== 0) cancelAnimationFrame(frame);
frame = 0;
emit();
},
},
new ByteLengthQueuingStrategy({ highWaterMark: 512 * 1024 }), // writable
new ByteLengthQueuingStrategy({ highWaterMark: 512 * 1024 }), // readable
);
}
export async function uploadWithProgress(
url: string,
file: File,
onProgress: (tick: ProgressTick) => void,
signal?: AbortSignal,
): Promise<Response> {
const counted = file.stream().pipeThrough(createProgressStream(file.size, onProgress));
const res = await fetch(url, {
method: "PUT",
body: counted,
duplex: "half", // mandatory whenever body is a ReadableStream
headers: {
"Content-Type": file.type || "application/octet-stream",
"X-Upload-Size": String(file.size), // the server gets no Content-Length
},
signal,
} as RequestInit & { duplex: "half" });
if (!res.ok) {
throw new Error(`upload failed: HTTP ${res.status} ${res.statusText}`);
}
return res;
}
// Call site: a native <progress> element driven by the ticks.
const bar = document.querySelector<HTMLProgressElement>("#bar")!;
const label = document.querySelector<HTMLSpanElement>("#pct")!;
export async function run(file: File): Promise<void> {
bar.max = 1;
const res = await uploadWithProgress("/api/objects/" + file.name, file, (tick) => {
// Cap the display at 99% until the response actually lands (see below).
bar.value = Math.min(tick.ratio, 0.99);
label.textContent = `${(tick.ratio * 100).toFixed(1)}%`;
});
bar.value = 1;
label.textContent = `stored as ${res.headers.get("ETag") ?? "unknown"}`;
}
Line-by-line on the critical parts
file.stream()returns aReadableStream<Uint8Array>that reads the file lazily from disk. Chrome emits 65,536-byte chunks, so a 500 MB file produces exactly 8,000transform()calls — cheap, because none of them touch the data.loaded += chunk.byteLengthruns beforecontroller.enqueue(chunk). Counting first means that if the downstream errors mid-write,loadedstill describes everything you handed off rather than silently dropping the last chunk.- Use
byteLength, neverlength. They are identical for aUint8Array, but the moment you put this transformer behind aTextEncoderStream, aCompressionStream, or a BYOB reader that hands you aDataViewor a wider typed array,lengthstarts counting elements instead of bytes and your percentage quietly drifts. controller.enqueue(chunk)forwards the exact sameUint8Array. Do notslice()orset()it into a new buffer: that doubles memory traffic on the hot path and buys nothing.flush()fires when the writable half closes, which is the moment the last byte of the file has been read out of the counting stream. It is the only reliable place to emit the finalloaded === file.sizetick, because a pendingrequestAnimationFramecallback might otherwise be cancelled by navigation.- The two
ByteLengthQueuingStrategyarguments set a 512 KB high-water mark on each side. Without them the defaultCountQueuingStrategy({ highWaterMark: 1 })lets exactly one chunk buffer, which is fine but makes the counter tick in lockstep with the socket; 512 KB smooths the reporting without meaningfully increasing memory. duplex: "half"is not optional. It declares that you will not read the response until you have finished writing the request, which is the only mode Chromium implements.X-Upload-Sizeexists because a streamed body is sent withTransfer-Encoding: chunkedand noContent-Length. If your backend enforces a size limit or preallocates storage, it needs the number from somewhere — and it must still verify the claim while reading, which is the job of server-side file validation.
What the counter actually measures
This is the part that gets teams into trouble. transform() runs when the consumer pulls a chunk, and the consumer is fetch’s internal body reader. fetch pulls as fast as the HTTP/2 flow-control window and the kernel socket send buffer will let it. So loaded means bytes accepted by the network stack, not bytes received by the server and definitely not bytes durably stored.
How large is the lead? Chrome advertises a 6 MB HTTP/2 connection window and a 6 MB stream window, and the kernel socket send buffer autotunes into the low megabytes. In practice the counter runs a few hundred kilobytes ahead on a slow link and up to about 6 MB ahead on a fast one — which for a 20 MB file means the bar reaches 100% while roughly a third of the payload is still in flight. On a link that stalls completely, the counter can sit at 100% for the entire duration of a 30-second timeout and then the fetch rejects.
Two rules follow. First, clamp the displayed value at 99% until the Response resolves, exactly as the call site above does. Second, never fire your “upload complete” side effects — enqueue a transcode, navigate away, delete the local copy — off a progress tick. Wait for the 2xx. If you need the difference between “sent” and “durably stored” surfaced to the user, that is what a server-pushed channel is for; streaming upload progress with server-sent events covers the second half of the bar.
Throttling the callback with requestAnimationFrame
At 64 KiB per chunk, a 100 Mbit/s link produces about 190 transform() calls per second and a gigabit link about 1,900. Calling setState or writing to the DOM on each one is how a progress bar ends up costing more CPU than the upload. The transformer above solves this with a one-slot coalescing pattern: transform() only schedules a frame if none is pending, so at most one callback runs per repaint — roughly 60 per second, and fewer when the tab is busy.
Two details are easy to get wrong. The first is the loaded !== reported guard in flushFrame: without it, a frame can fire with no new bytes and you push a duplicate render. The second is background tabs. requestAnimationFrame does not fire in a hidden tab, so the counter keeps advancing while the UI silently freezes — and then jumps when the user returns. If your progress feeds something other than pixels — analytics, a Web Lock heartbeat, a throughput estimate driving a time-remaining label — swap in a timer while the document is hidden:
let frame = 0;
let timer = 0;
function schedule(flushFrame: () => void): void {
if (frame !== 0 || timer !== 0) return;
if (document.visibilityState === "hidden") {
timer = self.setTimeout(() => {
timer = 0;
flushFrame();
}, 250); // background timers are clamped to ~1s anyway
} else {
frame = requestAnimationFrame(() => {
frame = 0;
flushFrame();
});
}
}
Keep the reported value monotonic. loaded only ever increases inside one stream, but if you aggregate several concurrent uploads into one bar — the usual pattern when you slice a file with Blob.slice — a retried part resets its own counter and the total can go backwards. Track per-part totals in a Map and sum them, rather than accumulating into a single number.
TransformStream versus xhr.upload.onprogress
The gap between these two is narrower than it is usually presented. Both mechanisms report bytes written into the network stack; neither reports acknowledgement. XMLHttpRequest emits its progress events from the network layer, so it sits marginally closer to the socket, but the difference is buffering noise, not a different quantity.
| fetch + TransformStream | xhr.upload.onprogress | |
|---|---|---|
| Browser support | Chromium 105+ only | Every browser since IE10 |
| HTTP/1.1 endpoint | Fails, needs h2 or h3 | Works |
| Content-Length sent | No, chunked only | Yes |
| Transform while sending | Yes — hash, compress, encrypt | No, body is fixed |
| Retry the same body | Needs a fresh stream | Re-send the same Blob |
| Event rate control | Yours to write | Browser-throttled to ~50 ms |
| Works in a Service Worker | Yes | No |
The practical rule: if you would have sent a plain File or FormData anyway, use XMLHttpRequest and stop reading — uploading files with fetch and FormData explains why fetch alone cannot do it. If the body is generated — an encrypted stream, a concatenated multipart body, a WebCodecs output — you were already going to stream, and the counter is fifteen lines.
Configuration gotchas
The bar sprints to 100% in two seconds behind nginx. With the default proxy_request_buffering on, nginx swallows the entire request body into client_body_temp_path at LAN speed before it opens the upstream connection, so your counter is measuring the browser-to-proxy hop and nothing else. The tell is in the error log at warn level: a client request body is buffered to a temporary file /var/lib/nginx/body/0000000021. Set proxy_request_buffering off; on the upload location — otherwise every progress number you show is a lie about a copy to a local disk.
Reusing the transformer for a retry double-counts, then throws. createProgressStream closes over loaded, so a second pipeThrough into the same instance would continue from the old total — except it never gets that far: the stream is already locked and you get TypeError: Failed to execute 'pipeThrough' on 'ReadableStream': Cannot pipe to a locked stream. Construct the whole pipeline inside the retry closure so each attempt calls file.stream() and createProgressStream() afresh, which is also what the backoff logic in resuming uploads after network loss expects.
An aborted upload never emits a final tick. flush() only runs on a clean close. When you call controller.abort() or the connection drops, the transform’s readable side is cancelled instead, so the last scheduled requestAnimationFrame may be dropped and your bar freezes at whatever it last painted. Add a cancel(reason) method to the transformer — supported from Chrome 114 — and emit one terminal tick there so the UI can distinguish “stopped at 62%” from “still going”.
Missing duplex or an HTTP/1.1 endpoint kills the request before any tick fires. Chrome throws TypeError: Failed to execute 'fetch' on 'Window': The duplex member must be specified for a request with a streaming body in the first case and rejects with a bare TypeError: Failed to fetch plus net::ERR_H2_OR_QUIC_REQUIRED in the second, and a presigned S3 PUT answers 411 Length Required because a streamed body carries no Content-Length. All three are transport problems rather than measurement problems — uploading with ReadableStream request bodies has the full table of who refuses what. Verify the request works with a fixed Blob body before you add the counter.
Verification
Drain the counted stream locally, with no network, and assert the arithmetic. Run this in a Chromium console:
const total = 7 * 1024 * 1024 + 13; // deliberately not a chunk multiple
const file = new File([new Uint8Array(total)], "probe.bin");
const ticks: number[] = [];
const counted = file.stream().pipeThrough(
createProgressStream(total, (t) => ticks.push(t.loaded)),
);
await new Response(counted).arrayBuffer(); // drains it
console.assert(ticks.at(-1) === total, `final tick ${ticks.at(-1)} !== ${total}`);
console.assert(
ticks.every((v, i) => i === 0 || v >= ticks[i - 1]),
"progress went backwards",
);
console.log(`${ticks.length} ticks for ${Math.ceil(total / 65536)} chunks`);
You should see far fewer ticks than chunks — that is the rAF coalescing working. Then verify the wire format against your endpoint:
curl -sv --http2 -X PUT --data-binary @probe.bin \
-H "Content-Type: application/octet-stream" \
-H "X-Upload-Size: 7340045" \
https://uploads.example.com/api/objects/probe.bin 2>&1 \
| grep -Ei "HTTP/2|transfer-encoding|content-length"
# Expect: > PUT ... HTTP/2, no content-length request header, < HTTP/2 200
Frequently Asked Questions
Does duplex: "half" let me read the response before the body finishes?
No — half duplex is the opposite promise. You are declaring that you will finish writing the request before reading the response, which is what every current Chromium build implements. Full duplex over HTTP/2 exists on the wire but fetch will not surface response chunks to you mid-upload, so a server that answers early simply has its bytes held until your stream closes.
Can I get progress for a multipart/form-data body this way?
Not by passing a FormData object — that gets serialised internally and the stream never passes through your transformer. You have to generate the multipart body yourself as a ReadableStream, emitting the boundary, the part headers, the file chunks and the trailer in order, which is described in multipart form data explained. At that point you control the byte stream and the counter works normally.
Do I need a custom queuing strategy on the TransformStream?
Not for correctness. The default is CountQueuingStrategy({ highWaterMark: 1 }) on both sides, which buffers a single chunk and applies backpressure immediately — perfectly safe. A ByteLengthQueuingStrategy with a 512 KB mark just lets a handful of chunks buffer so the counter is not gated on every socket write; above a few megabytes you are only adding memory and lengthening the gap between the counter and reality.
How does the server learn the file size without a Content-Length?
It does not, unless you tell it. Send the size in a custom header or a query parameter, then compare it against the bytes you actually received before committing the object. Treat the header as a hint from an untrusted client and still enforce a hard cap while reading, or a malicious caller streams for as long as your timeout allows.