TL;DR
A single multipart POST throws away the whole upload when the connection blinks. The tus protocol fixes that with HTTP-based resumable uploads. We'll stand up a Node
@tus/serverendpoint, wire atus-js-clientbrowser uploader, then kill the network mid-upload and watch it resume from the exact offset.📦 Code: github.com/USER/tus-video-upload (replace before publishing)
What we're building
A tiny upload app: a Node server that accepts resumable uploads and stores them (disk first, then S3), and a browser page that uploads a large video file and survives an interrupted connection. By the end you'll understand the three HTTP requests that make tus work, and you'll have a demo that resumes instead of restarting.
Versions: @tus/server 1.x, @tus/file-store, tus-js-client 4.x, Node 22.x. (tusd v2 exists in Go too; we'll use the Node server here.)
1. The 30-second protocol primer
tus is plain HTTP (built on RFC 9110), stable since v1.0. Three requests do everything:
| Request | Purpose |
|---|---|
POST |
Create an upload, declare Upload-Length, get back a URL |
PATCH |
Send a chunk at a given Upload-Offset
|
HEAD |
Ask "how many bytes do you already have?" before resuming |
That HEAD is the magic. After a drop, the client asks the server for the current offset and continues from there instead of guessing or restarting.
2. Stand up the Node server
mkdir tus-video-upload && cd tus-video-upload
npm init -y
npm install @tus/server @tus/file-store
// server.js
import http from 'node:http';
import { Server } from '@tus/server';
import { FileStore } from '@tus/file-store';
const tusServer = new Server({
path: '/files',
datastore: new FileStore({ directory: './uploads' }),
// basic guardrails
maxSize: 5 * 1024 * 1024 * 1024, // 5 GB cap
async onUploadCreate(req, upload) {
// hook for auth + validation; throw to reject
const type = upload.metadata?.filetype ?? '';
if (!type.startsWith('video/')) {
throw { status_code: 415, body: 'Only video uploads allowed' };
}
return {};
},
async onUploadFinish(req, upload) {
console.log(`✅ upload complete: ${upload.id} (${upload.size} bytes)`);
return {};
},
});
const server = http.createServer((req, res) => {
if (req.url.startsWith('/files')) return tusServer.handle(req, res);
res.writeHead(404).end();
});
server.listen(1080, () => console.log('tus server on http://localhost:1080/files'));
$ node server.js
tus server on http://localhost:1080/files
💡 Tip:
onUploadCreateis where auth lives. Check a session token fromreq.headersand throw to reject before a single byte is stored.
3. Wire the browser client
npm install tus-js-client
<!-- index.html -->
<input type="file" id="file" accept="video/*" />
<progress id="bar" value="0" max="100"></progress>
<pre id="log"></pre>
// upload.js
import * as tus from 'tus-js-client';
const input = document.getElementById('file');
const bar = document.getElementById('bar');
const log = (m) => (document.getElementById('log').textContent += m + '\n');
input.addEventListener('change', () => {
const file = input.files[0];
if (!file) return;
const upload = new tus.Upload(file, {
endpoint: 'http://localhost:1080/files',
retryDelays: [0, 1000, 3000, 5000, 10000], // auto-retry on failure
metadata: { filename: file.name, filetype: file.type },
onError: (err) => log('failed: ' + err),
onProgress: (sent, total) => {
const pct = ((sent / total) * 100).toFixed(1);
bar.value = pct;
log(`progress: ${pct}%`);
},
onSuccess: () => log('done: ' + upload.url),
});
// resume a previous upload of this file if one exists
upload.findPreviousUploads().then((prev) => {
if (prev.length) upload.resumeFromPreviousUpload(prev[0]);
upload.start();
});
});
The retryDelays array is what makes a transient drop invisible: tus-js-client backs off and retries automatically. findPreviousUploads is what lets a page reload pick up where it left off.
4. Prove it resumes
Start a big upload, then kill connectivity mid-flight and bring it back. You'll see it continue, not restart:
progress: 0.0%
progress: 41.7%
progress: 62.3%
failed: Error: tus: failed to upload chunk ... (network)
# ... retryDelays kicks in ...
progress: 62.3% <- HEAD asked the server; resumed from the stored offset
progress: 88.9%
progress: 100.0%
done: http://localhost:1080/files/9f3c...
You can watch the underlying requests in the network tab: a HEAD to the upload URL returns Upload-Offset: <bytes already stored>, then PATCH continues from there.
5. Move the store to S3 for production
Disk is fine for a demo. In production you want durable, multi-instance storage:
npm install @tus/s3-store
// swap FileStore for S3Store
import { S3Store } from '@tus/s3-store';
const datastore = new S3Store({
partSize: 8 * 1024 * 1024, // 8 MB parts
s3ClientConfig: {
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
},
});
⚠️ Note: resumable state must be durable. With
FileStore, a server restart with./uploadson ephemeral disk loses partial uploads. S3 (or a shared volume) fixes that and lets multiple server instances share upload state.
6. Lock it down: auth and expiry
An open upload endpoint is an open invitation to fill your storage with junk. The onUploadCreate hook runs before any bytes land, so it's the right place to check a token and reject early.
// in the Server({...}) config
async onUploadCreate(req, upload) {
const auth = req.headers['authorization'] ?? '';
const token = auth.replace(/^Bearer\s+/i, '');
const user = await verifySession(token); // your logic
if (!user) {
throw { status_code: 401, body: 'Not authorized' };
}
// stamp the owner onto the upload metadata for later
return { metadata: { ...upload.metadata, userId: user.id } };
}
Pass the token from the client through the headers option:
const upload = new tus.Upload(file, {
endpoint: 'http://localhost:1080/files',
headers: { Authorization: `Bearer ${sessionToken}` },
// ...rest as before
});
For cleanup, tus tracks an Upload-Expires value. Abandoned partials (someone closed the tab at 40%) otherwise sit in your store forever. The @tus/server exposes an expiration extension and a cleanUpExpiredUploads() call you can run on a schedule:
// cron.js: run periodically
import { tusServer } from './server.js';
const removed = await tusServer.cleanUpExpiredUploads();
console.log(`cleaned ${removed} expired uploads`);
⚠️ Note: without expiry cleanup, a public upload form becomes a slow storage leak. Schedule the cleanup before you launch, not after the bill arrives.
7. Things to know before you ship
- [ ] Set
maxSizeand validatefiletypeinonUploadCreate. - [ ] Handle
Upload-Expiresand garbage-collect abandoned partials. - [ ] Put the tus route behind auth (token in headers, checked in the create hook).
- [ ] Pin versions: the Go reference server
tusdmoved to v2 with breaking changes vs 1.13.0; the Node packages version independently. - [ ] Several managed platforms (Cloudflare Stream, Vimeo, Supabase) expose tus endpoints, so this same client code largely ports to them.
What's next
- Add Uppy on top of tus-js-client for a drag-and-drop UI with progress and retry built in.
- Trigger your transcoding pipeline from
onUploadFinish. - Read the tus 1.0 spec; the protocol is small enough to read in one sitting and it'll demystify the network tab.
The next time you build an upload box for anything large, don't reach for a single POST. Reach for the protocol that already solved the dropped-connection problem.
Top comments (0)