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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A hand-rolled Content-Range scheme | 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 | 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 | 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 | 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.
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
Related how-tos
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
-
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-OffsetandUpload-Complete, already runs on the tus servers it is meant to replace. ↩︎ Back to text -
Google’s documentation calls the response
308 Resume Incomplete. RFC 9110 defines 308 asPermanent Redirect, a meaning with no connection to uploads. Both meanings are in production. A redirect carries aLocationheader, whereas Google’s 308 carries aRangeheader only once some bytes are stored. ↩︎ Back to text -
The S3 multipart overview allows part numbers from 1 to 10,000 and requires every part’s
ETagin the final request. It adds that theETagof 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 -
Two AWS clients pick different sizes for the same job. The boto3 TransferConfig defaults both
multipart_thresholdandmultipart_chunksizeto 8388608 bytes, so a file becomes multipart at exactly the size of its first part. The JavaScript lib-storage Upload documents apartSizeof 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