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 fetchuploading 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 a fetch interceptor, a Service Worker, or a library that only speaks Request/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

  1. A Chromium-based browser at version 105 or newer. Firefox and Safari still ship no streaming request bodies, so feature-detect and fall back.
  2. An HTTPS endpoint negotiating HTTP/2 or HTTP/3. Chrome refuses to stream a request body over HTTP/1.1.
  3. TypeScript with lib: ["DOM", "ES2022"]. The duplex init member is not in the DOM typings yet, so you will cast the RequestInit.
  4. An endpoint that accepts Transfer-Encoding: chunked — a streamed body has no Content-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 a ReadableStream<Uint8Array> that reads the file lazily from disk. Chrome emits 65,536-byte chunks, so a 500 MB file produces exactly 8,000 transform() calls — cheap, because none of them touch the data.
  • loaded += chunk.byteLength runs before controller.enqueue(chunk). Counting first means that if the downstream errors mid-write, loaded still describes everything you handed off rather than silently dropping the last chunk.
  • Use byteLength, never length. They are identical for a Uint8Array, but the moment you put this transformer behind a TextEncoderStream, a CompressionStream, or a BYOB reader that hands you a DataView or a wider typed array, length starts counting elements instead of bytes and your percentage quietly drifts.
  • controller.enqueue(chunk) forwards the exact same Uint8Array. Do not slice() or set() 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 final loaded === file.size tick, because a pending requestAnimationFrame callback might otherwise be cancelled by navigation.
  • The two ByteLengthQueuingStrategy arguments set a 512 KB high-water mark on each side. Without them the default CountQueuingStrategy({ 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-Size exists because a streamed body is sent with Transfer-Encoding: chunked and no Content-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.

Where the byte counter sits in the upload pipeline A left-to-right pipeline from file.stream through a counting TransformStream into the fetch body queue and the socket, with the counted point marked early and the unacknowledged region marked at the right. Where the count happens file.stream() 64 KiB chunks TransformStream counts, forwards fetch body internal queue socket + H2 window in flight loaded += chunk.byteLength counted, not yet acknowledged The bar can read 100% while several megabytes are still in the socket and the H2 window. Only a resolved 2xx Response proves the server has the file.
The counter sits upstream of two buffers, so it always leads the server by the flow-control window plus the socket send buffer.

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.

Client counter versus server-acknowledged bytes over time A line chart over twelve seconds where the client-side counter reaches one hundred percent at nine seconds while the server acknowledgement curve trails behind and only reaches one hundred percent at ten and a half seconds. The bar finishes before the upload does 100% 50% 0% 0s 3s 6s 9s 12s 13% still in flight counter: bytes handed to the network server: bytes acknowledged A 45 MB upload on a 40 Mbit link: the 13% gap is the ~6 MB in-flight window.
The client curve leads by a constant offset equal to the buffered bytes — which is why the last percent must come from the response, not the counter.

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
Decision path between the streaming and XHR progress approaches A decision tree asking whether the body must be transformed while sending and whether the endpoint speaks HTTP/2, routing to the TransformStream approach or to XMLHttpRequest. Which progress source Body transformed in flight? no yes Use xhr.upload.onprogress Endpoint on h2 or h3? yes Counting TransformStream no
Reach for the streaming counter when the body must be produced or transformed on the fly; otherwise XHR is fewer moving parts.

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.