# ADR 0215: Upload progress is counted where the bytes leave

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0215-upload-progress-is-counted-where-the-bytes-leave.md

---

# ADR 0215 — Upload progress is counted where the bytes leave

## Context

The client guide said it plainly: stitchkit does not expose upload progress,
because Fetch has no portable upload event. A consuming project that shows a
recording's upload live (on a 200 ms link, most of a three-second wait is bytes
in flight) had to get it anyway, and the only seam was `ClientFetch`. It built a
registry keyed by fields of its own multipart body, re-parsed each request the
client handed its transport to find the key, re-read the body to send it as a
counted stream in Bun and through its own `XMLHttpRequest` in the browser. That
leaned on the form in which the client calls its transport — nothing promised
it, and the first live uploads went without progress because the tests called
the transport with `FormData` while the client calls it with a `Request`.

Every product that uploads a file writes the same thing, so it belongs in the
client, keyed by the call rather than by the body.

## Decision

1. **`onUploadProgress` is a per-call option**, beside `signal`, on
   `withOptions` for every call with a body. It hears
   `{ sentBytes, totalBytes, attempt }`: the first report is `0`, values only
   grow, the last of an attempt equals `totalBytes`. `sentBytes` counts bytes
   handed to the network, not bytes the server accepted. A `GET`, `HEAD`,
   `DELETE` or streaming endpoint refuses the option with a `TypeError` at the
   call.
2. **One wrapper, two routes, chosen per call.** The option wraps the fetch the
   call would have used; each wrapper call is one attempt, so a transport retry
   counts again under the next number. The body is encoded once — which is also
   where Bun's multipart boundary header is copied before it is forgotten.
   - **Stream** — Bun, Node, SSR, an injected `fetch`, a unix socket: the body
     leaves as a `ReadableStream` of 64 KiB pieces with an exact
     `content-length` and `duplex: 'half'`, and a piece counts when the
     transport pulls it (`highWaterMark: 0`, nothing read ahead). Measured on
     Node 24: the server sees the declared length, no chunked encoding.
   - **XHR** — a browser on its own fetch: `XMLHttpRequest` with the request's
     headers, credentials, cancellation and timeout, its response rebuilt as a
     `Response` so error parsing is the fetch path's. A streaming request body
     is not portable (Chromium only over HTTP/2, Safari never), and upload
     events are. Measured in Chromium on a 2 MB/s link: 23 reports over an
     8 MB body.
3. **Without the option the request takes the path it always did** — the
   wrapper is not installed; a test holds that the transport still receives the
   untouched `FormData` / `Request`.
4. **The Ky client raises a server's error envelope after its retries**
   (`beforeError`), not from `afterResponse`. Raised there, it ended the request
   before Ky saw the status, so `retry.statusCodes` never fired against a
   stitchkit server — every failure of which is an envelope. Found by the
   retried-503 test this option needed.

## Consequences

- A consumer's transport wrapper, its registry and its request re-parsing go;
  progress is one line in the call.
- The body of a counted call is held in memory once (it is, in practice,
  already: a `FormData` of a picked file is encoded before it is sent). A file
  larger than one request goes through ADR 0216 in parts, which is where large
  bodies belong anyway.
- An injected transport always takes the stream route. One that buffers a
  stream before sending reports the whole body at once — honest about what it
  pulled, useless as a progress bar; the unix transport is one, over a local
  socket where the whole body leaves in milliseconds.
- `credentials: 'omit'` is refused on the XHR route: XHR cannot drop
  same-origin cookies, and sending them silently would be the wrong request.
- The XHR route reads the response whole before the call resolves; that is why
  a streaming endpoint refuses the option.

## Not done

- Download progress. A response is already a stream the caller can count; no
  consumer has asked.
- A choice of route by the caller. The route follows from where the client
  runs and who owns the transport; a knob would let a browser pick the route
  that does not work there.
