How-to › Move data in and out

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.

Audience
API consumer
Level
intermediate
Topic
Run bulk jobs and move files
Languages
TypeScript and Python
Verified

A three-gigabyte upload dies at 92 percent when the laptop changes networks, and the browser starts it again from zero. The second attempt dies too. Your support queue fills with people on hotel Wi-Fi who have watched the same progress bar three times, and the server has already received most of the file twice.

What you get

You will end up with an upload that continues from the last byte the server confirmed, after a dropped connection and after a closed tab. A checksum on the server proves the bytes arrived once and in order. This is for you if your users upload files big enough to outlive a network connection.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hand-rolled Content-Range schemeYou own both ends and want no dependency on either sideEvery rule is yours to write and to test: the offset query, the mismatch response, expiry, and cleanup of abandoned uploadsA server you do not own already speaks tus or a cloud protocol
Google resumable uploadsThe destination is a bucket at Google or a Google APIChunks in multiples of 256 KiB, a session URI valid for one week, and a 308 that means something no other server means by itThe file goes anywhere that is not Google
S3 multipart uploadThe destination is S3 or a bucket that speaks its APIParts 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 uploadFiles under the minimum part size, or a server that is not a bucket
tus with tus-js-clientYou run the upload server, or use one that speaks tus, and want clients in many languagesA tus server to operate, and a client that must be told where to remember upload URLs outside a browserThe 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.

    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 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 behind a stand-in that destroys the first connection arriving at the 16 MiB offset.

node demo.mjs
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

        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.

python3 resume_demo.py
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 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, 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.

  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))
node --test upload.test.mjs
# 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.

python3 -m unittest resume_test.py 2>&1 | tail -n 1
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:

node pitfall.mjs
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 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

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.

Footnotes

  1. The tus protocol 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 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. ↩︎ Back to text

  2. Google’s documentation calls the response 308 Resume Incomplete. RFC 9110 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. ↩︎ Back to text

  3. The S3 multipart overview 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. ↩︎ Back to text

  4. Two AWS clients pick different sizes for the same job. The boto3 TransferConfig 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 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.