DEV Community

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

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: 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
<!-- 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 ./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 } };
}

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 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.

Comments

No comments yet. Start the discussion.