# How to upload a large file with resumable chunks

> Resume an upload from the last byte the server confirmed after a dropped connection or a closed tab, with tus, S3 multipart, Google resumable uploads, or your own.

Source: https://voxgig.com/howto/upload-a-large-file-with-resumable-chunks

- Audience: api-consumer
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Split the file into chunks and let the server own the offset. With tus, tus-js-client sends 4 MiB PATCH requests, retries a dropped one from the offset the server reports, and resumes after a reload when you give it a URL storage. S3 multipart and Google's resumable protocol do the same with parts and Content-Range. Whichever you pick, persist the upload URL before the first byte, or a reload starts over.

---
## You will need

Node 22 or later for the tus sample, Python 3.11 or later for the hand-rolled one, and a
server that speaks one of the four protocols below. Verified 2026-09-25 against Node 22.22.2,
tus-js-client 4.3.1, @tus/server 2.4.5, @tus/file-store 2.1.1 and Python 3.11.15. The tus
server here runs on 127.0.0.1 from the code directory, while the S3 and Google rows come from
their documentation rather than a run of either service.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A hand-rolled Content-Range scheme](https://www.rfc-editor.org/rfc/rfc9110.html#name-content-range) | You own both ends and want no dependency on either side | Every rule is yours to write and to test: the offset query, the mismatch response, expiry, and cleanup of abandoned uploads | A server you do not own already speaks tus or a cloud protocol |
| [Google resumable uploads](https://docs.cloud.google.com/storage/docs/performing-resumable-uploads) | The destination is a bucket at Google or a Google API | Chunks in multiples of 256 KiB, a session URI valid for one week, and a `308` that means something no other server means by it | The file goes anywhere that is not Google |
| [S3 multipart upload](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html) | The destination is S3 or a bucket that speaks its API | Parts of at least 5 MiB, at most 10,000 of them, an `ETag` per part you must send back, and storage billed until you complete or stop the upload | Files under the minimum part size, or a server that is not a bucket |
| [tus with tus-js-client](https://tus.io/protocols/resumable-upload) | You run the upload server, or use one that speaks tus, and want clients in many languages | A tus server to operate, and a client that must be told where to remember upload URLs outside a browser | The bytes must land in a cloud bucket without a server of yours in between |

The trade is what the server already supports against how much state the client has to keep.
tus and Google keep one URL per upload and let the server answer where it got to, so the
client persists one string. S3 hands the client an upload id plus an `ETag` for every part,
and the client must send all of them back to complete. A scheme of your own can be as small as
the Python sample below, and every corner case it meets is yours.

## Let the server own the offset

The core of tus is three headers. A `HEAD` on the upload URL answers with `Upload-Offset`, a
`PATCH` carries the bytes from that offset with `Content-Type: application/offset+octet-stream`,
and a `PATCH` whose offset is not where the server is gets a `409`.[^1] The client below never
computes an offset itself; it asks.

```js title="upload.mjs"
    const up = new Upload(file, {
      endpoint,
      chunkSize: 4 * MiB,
      retryDelays: [100, 200, 400],
      urlStorage: new FileUrlStorage(stateFile),
      storeFingerprintForResuming: true,
      removeFingerprintOnSuccess: true,
      metadata: { filename: 'sample.bin' },
```

`chunkSize` is set only because a reverse proxy in front of a tus server usually caps request
bodies. The [tus-js-client documentation](https://github.com/tus/tus-js-client/blob/main/docs/api.md)
says the default of `Infinity` sends the whole file in one `PATCH` and meets a `413`. In Node
the default `urlStorage` discards everything, so `FileUrlStorage` and `storeFingerprintForResuming`
are what let a second process find the upload the first one started. `retryDelays` is the
network half: a `PATCH` that dies is retried after a `HEAD`, from wherever the server got to.

## Close the tab, drop the network, and resume

Two attempts at one 24 MiB file, with the server from `tus-server.mjs`, a
[@tus/server](https://github.com/tus/tus-node-server#readme) behind a stand-in that destroys
the first connection arriving at the 16 MiB offset.

```bash
node demo.mjs
```

```text output
file: 25165824 bytes, sha256 b0bd6fec328d324d

first attempt, the tab closes after two chunks
  previous uploads remembered for this file: 0
  chunk 1 accepted, 4194304 of 25165824 bytes on the server
  chunk 2 accepted, 8388608 of 25165824 bytes on the server
  stopped with 8388608 bytes accepted

second attempt, a fresh client with the same state file
  previous uploads remembered for this file: 1
  chunk 1 accepted, 12582912 of 25165824 bytes on the server
  chunk 2 accepted, 16777216 of 25165824 bytes on the server
  attempt 1 failed with ECONNRESET, retrying
  chunk 3 accepted, 20971520 of 25165824 bytes on the server
  chunk 4 accepted, 25165824 of 25165824 bytes on the server
  finished: true, same upload URL as the first attempt: true

server has 25165824 bytes, sha256 b0bd6fec328d324d, matches: true
PATCH requests seen by the server, by starting offset: 0, 4194304, 8388608, 12582912, 16777216, 16777216, 20971520
connections the network stand-in dropped: 1
```

The list of `PATCH` offsets is the evidence. The second attempt's first chunk starts at
8388608, which is where the first attempt stopped. The offset 16777216 appears twice because
the first request at it was cut, and the retry asked the server before sending again. No
offset before 16 MiB was sent twice, and the checksum on the server matches the file.

## Do the same with the standard library

When neither end speaks tus, the same shape is small enough to write. The Python server keeps
an offset per session on disk and answers in the form Google's protocol uses. A `308` with a
`Range` header means the upload is incomplete, and a status query is a request that sends
`Content-Range: bytes */TOTAL` with an empty body.[^2]

```py title="resume_server.py"
        data = self.rfile.read(length)
        # The offset is checked inside append, under the lock that guards the write.
        # A check out here lets a retry that overlaps its first attempt append twice.
        meta, appended = self.store.append(upload_id, first, data)
        if not appended:
            # The client and the server disagree about where the upload got to.
            # The server wins, and says where it is.
            return self._progress(meta, status=409)
        self._progress(meta)
```

Under the lock, one of two overlapping attempts at the same offset appends, and the other is
told the real offset.

The client writes the session URL to a state file before it sends a byte, and on a second run
asks the server where to continue. `--stop-after 3` ends the first process with `os._exit`,
which is what a killed process looks like: no cleanup, no goodbye.

```bash
python3 resume_demo.py
```

```text output
file: 12582912 bytes, sha256 40da7726d84eaa1f

first run, killed after three chunks
  created a session, state written before the first byte
  chunk sent, server offset now 2097152
  chunk sent, server offset now 4194304
  chunk sent, server offset now 6291456
  process killed after 3 chunks
  state file still present: True

second run, same state file
  resuming the session in the state file from offset 6291456
  chunk sent, server offset now 8388608
  chunk sent, server offset now 10485760
  chunk 10485760-12582911 accepted, upload complete
  state file removed on completion: True

server has 12582912 bytes, sha256 40da7726d84eaa1f, matches: True
```

`Content-Range` is defined in [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-content-range)
as `bytes first-last/complete`, and the `*/complete` form is the one the status query borrows.
The IETF is standardizing this whole shape as
[Resumable Uploads for HTTP](https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/),
with its own `Upload-Offset` and `Upload-Complete` headers. A scheme written today can be
shaped to match what that draft will make official.

## Check it worked

The assertion that matters compares checksums on the server side and reads the list of
offsets the server saw, because a client can report success without the bytes having landed.

```js title="upload.test.mjs"
  const second = await upload(file, { endpoint: server.endpoint, stateFile })
  assert.equal(second.finished, true)
  assert.equal(second.url, first.url, 'the same upload resource, not a new one')
  assert.deepEqual(server.state.patches, [0, 4 * MiB, 8 * MiB, 12 * MiB, 16 * MiB, 20 * MiB],
    'no offset was uploaded twice')

  const stored = readFileSync(join(server.directory, second.url.split('/').pop()))
  assert.equal(stored.length, file.length)
  assert.equal(sha256(stored), sha256(file))
```

```bash
node --test upload.test.mjs
```

```text output
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
```

The other five pin the retry after a dropped connection and the `409` on a stale offset. They
also pin the second upload a client with no state file creates, the fingerprint removed after
success, and a server that deletes what it stored when it closes. The Python side has its own
eight in `resume_test.py`, two of them for a state file that outlived its session and one for
an overlapping retry.

```bash
python3 -m unittest resume_test.py 2>&1 | tail -n 1
```

```text output
OK
```

## When it goes wrong

A page reload starts the upload again from zero. The client kept no record of the upload URL,
so it cannot find the upload it started. In a browser tus-js-client stores the URL in
`localStorage` by default; in Node it stores nothing unless you hand it a `FileUrlStorage`.
This is what the default looks like from the server's side:

```bash
node pitfall.mjs
```

```text output
previous uploads remembered for this file: 0
first attempt stopped at 8388608 bytes
previous uploads remembered for this file: 0
second attempt uploaded 25165824 bytes, same URL as the first: false
uploads on the server: 2
  abandoned 8388608 bytes
  complete  25165824 bytes
```

The abandoned 8 MiB stays until something removes it. tus servers expire uploads through the
`expiration` extension, and on S3 the parts of an incomplete multipart upload are billed until
you complete or stop it, so add a lifecycle rule with
[`AbortIncompleteMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html)
before the first user closes a tab.[^3]

Every chunk answers `413`. A proxy between the client and the server caps request bodies, and
the chunk is bigger than the cap. Set `chunkSize` below the smallest limit on the path.

The server answers `409` and the client loops. The client resent a chunk from an offset it
remembered rather than one it asked for. Ask with `HEAD`, or with the `*/total` status query,
after every failure and never trust a local offset across a retry.

A state file outlives its session. The server expired the upload, and the status query answers
`404`. `resume_client.py` drops the stale session and starts a new one, because a client that
stops there stops on every run that finds the file. It also removes a state file whose upload
the server reports complete.

The same file uploaded twice resumes the finished upload. tus-js-client starts removing the
fingerprint on success and calls `onSuccess` without waiting for it, so a second `Upload` of
the same bytes created inside that callback can still find the old entry. The last test in
`upload.test.mjs` waits for the removal before it starts the second upload.

## When not to do this

Do not chunk a file that fits in one request. Below a few megabytes the round trips cost more
than a retry of the whole thing, and S3 will not accept a part under 5 MiB unless it is the
last one. Send it once, and retry it whole.

Do not write a scheme of your own when the server already speaks one. tus, S3, and Google
each took years to settle their edge cases, and the Python sample here settles none of expiry,
authentication, or two clients sharing one session. Use it to understand the shape, or when
both ends are yours and small.

Do not keep the offset on the client. Every protocol on this page lets the server say where
the upload is. A client that resumes from its own number sends bytes the server already has,
or skips bytes it lost.

Do not skip the lifecycle rule because uploads usually finish. The ones that matter here are
the ones that do not, and each leaves parts behind that cost money until deleted.[^4]

## Related how-tos

- [Poll a job status endpoint with backoff](/howto/poll-a-job-status-endpoint-with-backoff)

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

## Last verified

Verified 2026-09-25 against Node 22.22.2, tus-js-client 4.3.1, @tus/server 2.4.5,
@tus/file-store 2.1.1 and Python 3.11.15. Every output block is what the command preceding it
printed, against servers on 127.0.0.1 in the code directory. The network drop is a stand-in
that destroys one connection at a chosen offset instead of a real network failure. S3 and Google
did not run.

[^1]: The [tus protocol](https://tus.io/protocols/resumable-upload) reached 1.0.0 on
2016-03-25 and has not needed a 1.1. Ten years later the IETF draft
[draft-ietf-httpbis-resumable-upload](https://datatracker.ietf.org/doc/draft-ietf-httpbis-resumable-upload/)
is at its twelfth revision, edited by Marius Kleidl of Transloadit, the company behind tus,
with Guoye Zhang of Apple and Lucas Pardue of Cloudflare. The protocol it describes, with headers
such as `Upload-Offset` and `Upload-Complete`, already runs on the tus servers it is meant to
replace.

[^2]: Google's [documentation](https://docs.cloud.google.com/storage/docs/performing-resumable-uploads)
calls the response `308 Resume Incomplete`. [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-308-permanent-redirect)
defines 308 as `Permanent Redirect`, a meaning with no connection to uploads. Both meanings are in
production. A redirect carries a `Location` header, whereas Google's 308 carries a `Range` header
only once some bytes are stored.

[^3]: The S3 [multipart overview](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html)
allows part numbers from 1 to 10,000 and requires every part's `ETag` in the final request.
It adds that the `ETag` of the finished object is not necessarily an MD5 of its data. It also
says that after you stop a multipart upload, part uploads still in progress can succeed or
fail anyway. Freeing all the storage therefore means stopping only after every part is done, so
the cancel has to wait for the work it is canceling.

[^4]: Two AWS clients pick different sizes for the same job. The
[boto3 TransferConfig](https://docs.aws.amazon.com/boto3/latest/reference/customizations/s3.html)
defaults both `multipart_threshold` and `multipart_chunksize` to 8388608 bytes, so a file
becomes multipart at exactly the size of its first part. The JavaScript
[lib-storage Upload](https://github.com/aws/aws-sdk-js-v3/tree/main/lib/lib-storage) documents
a `partSize` of 5 MB with four parts in flight. Both clients follow the same service rules, and
each team chose the part size for itself.