DEV Community

Mason K
Mason K

Posted on

Build resumable video uploads with tus, Node, and tus-js-client

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/server endpoint, wire a tus-js-client browser 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
Enter fullscreen mode Exit fullscreen mode
// 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'));
Enter fullscreen mode Exit fullscreen mode
$ node server.js
tus server on http://localhost:1080/files
Enter fullscreen mode Exit fullscreen mode

💡 Tip: onUploadCreate is where auth lives. Check a session token from req.headers and throw to reject before a single byte is stored.

3. Wire the browser client

npm install tus-js-client
Enter fullscreen mode Exit fullscreen mode
<!-- index.html -->
<input type="file" id="file" accept="video/*" />
<progress id="bar" value="0" max="100"></progress>
<pre id="log"></pre>
Enter fullscreen mode Exit fullscreen mode
// 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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: resumable state must be durable. With FileStore, a server restart with ./uploads on 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 } };
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

⚠️ 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 maxSize and validate filetype in onUploadCreate.
  • [ ] Handle Upload-Expires and garbage-collect abandoned partials.
  • [ ] Put the tus route behind auth (token in headers, checked in the create hook).
  • [ ] Pin versions: the Go reference server tusd moved 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)