Skip to content

Send TUS uploads as bounded 32 MiB chunks - #108

Merged
morepriyam merged 2 commits into
mainfrom
fix/tus-chunked-upload
Jul 22, 2026
Merged

Send TUS uploads as bounded 32 MiB chunks#108
morepriyam merged 2 commits into
mainfrom
fix/tus-chunked-upload

Conversation

@morepriyam

Copy link
Copy Markdown
Collaborator

Fixes #89 — successor to #87 (whose fix, #88, was reverted in da1f0f6; main currently has neither the durability nor the behavior #87 was closed for).

Problem

tus-client.ts sends one PATCH from the current offset to EOF. The opensource-server edge buffers the entire request body before proxy_pass (mieweb/opensource-server#395), so the upstream never sees a byte until the body completes — an interrupted PATCH commits zero bytes. Confirmed against the production container: 31.5 MB sent → HEAD returns Upload-Offset: 0. Large uploads on flaky networks can loop from byte 0 forever.

Bounded chunks are the standard TUS client answer to body-buffering middleboxes — it's why tus-js-client exposes chunkSize ("a good example of when to use it: if the server or proxy has a limit on the request body size"), and the tus resumable upload protocol makes every completed PATCH durable via Upload-Offset — so a drop loses at most one chunk instead of everything.

Why 32 MiB (and why the 10 MiB version was rightly reverted)

The reverted change defaulted to 10 MiB, which measurably hurt throughput. Benchmarked against the production edge (64 MB file):

Strategy Speed
Single PATCH to EOF (current main) 20.4 MB/s
32 MB chunks, kept-alive connection 20.5 MB/s
16 MB chunks, kept-alive connection 16.3 MB/s
8 MB chunks, kept-alive connection 10.1 MB/s
8 MB chunks, fresh TLS conn per chunk 5.1 MB/s

Small chunks pay a fixed ~0.4 s per-chunk dead time (the proxy holds each body until complete) plus TLS setup when connections aren't reused. At 32 MiB the overhead is measurement noise — durability without giving up single-PATCH throughput. chunkSizeBytes stays configurable so it can be raised toward a single remainder once the edge streams bodies (mieweb/opensource-server#395's modsecurity off fix).

Design

  • Server offset is the sole source of truth. The loop advances only on each 204's Upload-Offset (no per-chunk HEAD on the happy path). Any failure — or a 204 without forward progress — falls back to the retry wrapper, whose next attempt re-HEADs for the authoritative offset before sending more bytes, per the protocol's offset semantics.
  • One code path. No "fall back to chunking on failure" heuristic: chunk boundaries must exist on the first attempt to make progress durable, not only after something already went wrong.
  • Bounded staging. prepareChunkSource stages exactly one chunk via FileHandle seek + bounded read, capping the resume-copy memory spike at 32 MiB — the current code reads the entire remainder into memory on every resume, unbounded by anything but the file size. A file that fits in one chunk from offset 0 still uploads directly with no copy.
  • The byte-carrying PATCH stays on expo-file-system's native upload task (File.upload, BINARY_CONTENT), since React Native's fetch/Blob cannot carry raw byte bodies. Progress reporting only advances on server-acknowledged offsets, so displayed progress is always durable progress.

Tests

tus-client.test.ts covers: sequential bounded chunk sequencing with correct Upload-Offset headers, the 32 MiB default, advance-on-204 without per-chunk HEADs, a 204 lacking a usable Upload-Offset treated as transient (re-HEAD, continue from authoritative offset), mid-transfer failure resuming from the server's offset, and the existing create/resume/redirect/origin-pinning suite. All logic is dependency-injected and runs under the pure-logic jest config.

Verification

  • npx tsc --noEmit clean
  • npx eslint clean
  • npx jest — 10 suites, 78 tests pass

Re-lands the chunked PATCH loop (previously reverted in da1f0f6) with the
chunk size retuned from 10 MiB to 32 MiB.

Behind an edge that buffers the full request body before proxying, an
interrupted single PATCH commits nothing: the server never sees a byte
until the body completes, so every drop resumes from offset 0 and large
uploads on flaky networks can loop forever. Bounded chunks are the
standard TUS answer (tus-js-client exposes chunkSize for exactly this):
each completed chunk is durably on the server, so a drop loses at most
one chunk.

The original 10 MiB default measurably hurt throughput, which is why it
was reverted. Measured against the production edge (64 MB file):

  single PATCH to EOF                 20.4 MB/s
  32 MB chunks, kept-alive conn       20.5 MB/s
  16 MB chunks, kept-alive conn       16.3 MB/s
   8 MB chunks, kept-alive conn       10.1 MB/s
   8 MB chunks, fresh TLS per chunk    5.1 MB/s

Below ~32 MB the fixed per-chunk dead time (~0.4 s while the proxy
spools each body) dominates; at 32 MB it is measurement noise, so this
keeps single-PATCH throughput while making progress durable.

Details:
- Transfer loop sends chunkSizeBytes-bounded PATCHes and advances only
  on each 204's Upload-Offset — the server stays the sole source of
  truth and the happy path pays no per-chunk HEAD. Any failure or a 204
  without forward progress hands control back to the retry wrapper,
  which re-HEADs for the authoritative offset before moving more bytes.
- prepareChunkSource stages one bounded chunk via FileHandle seek+read,
  capping the per-resume staging copy at the chunk size — the previous
  code read the entire remainder into memory at once, unbounded by
  anything but file size. A file that fits in one chunk from offset 0
  still uploads directly with no copy.
- chunkSizeBytes stays configurable so deployments whose edge streams
  request bodies can raise it toward a single remainder.

Fixes #89

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements always-chunked TUS uploads (default 32 MiB) so uploads make durable progress when an edge proxy buffers request bodies, while preserving throughput via larger chunk sizes and connection reuse.

Changes:

  • Add bounded-chunk upload loop driven by server Upload-Offset in uploadViaTus, with a configurable chunkSizeBytes defaulting to 32 MiB.
  • Update the native uploader to stage exactly one chunk (bounded temp copy) and expose it as uploadChunkNative.
  • Expand/adjust Jest coverage to validate chunk sequencing, offset discipline, default chunk size, and retry/re-HEAD resume behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/features/upload/tus-client.ts Switch TUS transfer logic to bounded chunk PATCHes and add chunk-size configuration + offset parsing utilities.
src/features/upload/tus-client.test.ts Update tests to assert chunk sequencing, default 32 MiB chunk size, and retry/re-HEAD offset discipline.
src/features/upload/transports/tus-server-transport.ts Wire transport to the new uploadChunkNative injection point.
src/features/upload/native-chunk-upload.ts Replace remainder-staging with per-chunk staging (prepareChunkSource) and export uploadChunkNative.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/features/upload/tus-client.ts
Comment thread src/features/upload/tus-client.ts Outdated
…ress

Review follow-ups:
- Reject a non-positive/non-finite/fractional chunkSizeBytes up front with a
  non-retryable TusUploadError instead of entering a non-advancing upload
  loop that retries forever (covered by new tests).
- Drop the in-flight per-chunk onProgress forwarding: behind a body-buffering
  proxy, bytes sent are not bytes committed, so progress now advances only on
  each 204's server-acknowledged Upload-Offset, matching the documented
  offset discipline. The unused per-chunk onProgress hook is removed from the
  UploadChunk contract and uploadChunkNative.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@morepriyam
morepriyam merged commit 6fb63c4 into main Jul 22, 2026
4 checks passed
morepriyam added a commit that referenced this pull request Jul 22, 2026
Send TUS uploads as bounded 32 MiB chunks
morepriyam added a commit that referenced this pull request Jul 22, 2026
…ing as a knob

Reverts the 32 MiB always-chunked default from #108 to the standard TUS
shape: one PATCH from the current offset to EOF. In practice the chunk
loop was what made big uploads crawl on device — a 32 MiB temp copy per
chunk, a fresh background-URLSession task per chunk, the edge's spool
dead time paid per chunk, and the JS loop freezing between chunks the
moment the screen locks (progress then creeps at <=32 MiB per rare BG
wake). A single PATCH is one native background transfer that streams
straight from the source file and survives lock/backgrounding.

chunkSizeBytes stays as an explicit opt-in escape hatch for deployments
behind a body-buffering edge, with the 32 MiB benchmark numbers kept in
its doc. Durability through the buffering proxy is deferred to the
server-side fix (mieweb/opensource-server#395).

Also:
- restore smooth in-flight progress: native task ticks are forwarded as
  offset + bytesSentThisAttempt (clamped), re-anchored on each 204's
  server-acknowledged Upload-Offset, and honestly stepped back to the
  re-HEAD offset after a failure
- resume staging is now memory-bounded: the remainder temp copy streams
  through 8 MiB FileHandle reads instead of one whole-remainder
  readBytes allocation, so remainder-sized PATCHes do not reintroduce
  the OOM half of #92
@morepriyam
morepriyam deleted the fix/tus-chunked-upload branch July 22, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

upload: re-land chunked TUS PATCH with 32–64 MB chunks + connection reuse (successor to #87)

2 participants