Back to Resources
Engineering

Resumable S3 Uploads: How to Use TUS and Uppy in 2026

Why standard HTTP POST is a liability for multi-gigabyte assets and how the TUS protocol solves the 'resume from zero' nightmare.

Resumable S3 Uploads: How to Use TUS and Uppy in 2026

AWS documentation makes multipart uploads sound like a solved problem. It is not. Try asking someone on flaky hotel Wi-Fi to push a 20GB video file through your web app, and you quickly learn why.

When you build an upload workflow with plain presigned URLs, you hit an architectural brick wall. Presigned URLs give you the raw pipes to push chunks into object storage. What they completely ignore is frontend client state.

Here is the failure loop. Your user's progress bar crawls up to 83%. Their laptop lid closes, or their network drops for four seconds. Because standard AWS SDK state lives exclusively in ephemeral JavaScript memory, that context evaporates immediately. A network interruption forces a hard restart from zero bytes. Watch users retry a 15GB upload three times in a row, and you will see support tickets pile up fast. This is not a UI glitch. It is a fundamental architecture flaw.

What do teams do when they run into this? They try patching around it. They stash upload IDs inside IndexedDB. They track arrays of ETags by hand. They write fragile polling loops to refresh expiring presigned URLs on long-running transfers. It turns into an unmaintainable tangle within weeks, and anyone maintaining that codebase knows it.

Why We Switched to TUS

We threw away our homegrown multipart upload scripts and switched to the open TUS protocol.

TUS was engineered from the ground up for resumable transfers. It avoids blind file slicing and hoping for the best. Instead, it maintains a negotiated, verified state between client and server across the entire transfer lifecycle.

When a network connection cuts out, the client never guesses where to pick back up. It issues a quick query to the server: what was the last byte you safely received? The server replies with the exact confirmed byte boundary. The client streams only the remaining slice. Zero wasted bandwidth. Zero restarting from scratch.

What Actually Worked For Us

The first instinct for most engineers is spinning up a self-hosted tusd Go binary inside Docker or on an EC2 box. Unless you genuinely want to babysit storage servers at 2 AM, step back before taking that route.

Here is what happens when you roll your own. Even if you configure tusd with an S3 backend to avoid local disks, you still have to maintain the host container, write auth middleware to bridge into your primary API, handle orphan part cleanup, and scale the instances during upload spikes. You wanted direct-to-storage uploads to eliminate a proxy layer. Now you have built a proxy anyway, and you are the one getting paged when it runs out of memory.

The architecture that holds up long term streams TUS chunks straight into S3 multipart parts without writing temporary files to disk. If you run that pipeline yourself, every byte still has to traverse your own fleet. Offload that ingestion to a managed TUS endpoint, and the file bytes stream straight from the browser into your bucket while your backend simply mints short-lived tokens.

Wiring It Up With Rilavek

Rilavek gives you a managed TUS endpoint that streams uploads directly into your S3 bucket. Data never touches our disks, and you never have to maintain a tusd cluster. You connect your target bucket, create a Pipe (an ingestion route), and get a ready-to-use TUS URL. The free tier includes 10GB/month of transfer with zero credit card commitment.

New to Rilavek? Follow the 5-minute setup guide to connect your S3 bucket and create your first Pipe, then jump back here to wire up the React component.

Install the packages

npm install @uppy/core @uppy/tus @uppy/react @uppy/dashboard @uppy/golden-retriever

The component

Here is a clean Next.js component using Uppy and Rilavek as the TUS target.

import React, { useEffect, useRef } from 'react';
import Uppy from '@uppy/core';
import Tus from '@uppy/tus';
import GoldenRetriever from '@uppy/golden-retriever';
import { Dashboard } from '@uppy/react';
import '@uppy/core/dist/style.min.css';
import '@uppy/dashboard/dist/style.min.css';

export default function ResumableUploader({ pipeId, uploadToken }) {
  const uppyRef = useRef(null);

  if (!uppyRef.current) {
    uppyRef.current = new Uppy({
      id: 'video-ingestion',
      autoProceed: false,
      debug: true,
    });
  }

  useEffect(() => {
    const uppy = uppyRef.current;

    uppy.use(Tus, {
      endpoint: `https://upload.rilavek.com/pipes/${pipeId}/files/`,
      resume: true,
      autoRetry: true,
      retryDelays: [0, 1000, 3000, 5000],
      headers: {
        Authorization: `Bearer ${uploadToken}`,
      }
    });

    uppy.use(GoldenRetriever, { expires: 24 * 60 * 60 * 1000 });

    return () => uppy.destroy();
  }, [pipeId, uploadToken]);

  return <Dashboard uppy={uppyRef.current} />;
}

Notice the useRef wrapper. Uppy initializes exactly once across renders. If you create new instances inside render bodies, React Strict Mode will register duplicate plugins and clutter your console with warnings during local development.

The uploadToken prop passes directly to the component. Keep your root API keys far away from browser code. Never place secret keys in client environment files, and never hardcode them.

Instead, your backend server requests a short-lived token using your private credentials and hands that temporary string to the browser. If a user inspects their network tab, the temporary token only grants access to upload into that specific pipe.

const response = await fetch("https://rilavek.com/api/v1/tokens", {
  method: "POST",
  headers: {
    "Authorization": `Bearer sk_YOUR_PRIVATE_SENDER_TOKEN`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    pipeId: "pipe_123abc"
  })
});

// The response contains a temporary upload token and its expiration timestamp
const { token, expiresAt } = await response.json();

The "Micro-Insight" We Learned the Hard Way

Configuring the TUS plugin alone is only half the battle. If you use Uppy, you must include GoldenRetriever. Skipping this trips up nearly everyone.

Without GoldenRetriever, TUS can only resume sessions that stay resident in active browser memory. Close the tab? That memory clears out. The resume capability you thought you shipped disappears with it.

GoldenRetriever saves the in-flight upload metadata inside IndexedDB. That gives your TUS client the exact state it needs to re-sync with the remote stream when the user re-opens the page.

That is what separates theoretical resumability from a resilient production feature. With IndexedDB persistence enabled, a user can close their laptop, head home, open the browser the next morning, and resume at the exact byte where they stopped.

What Happens Under the Hood

When the first chunk of a TUS upload reaches Rilavek, the server calls CreateMultipartUpload on your destination bucket and maps the resulting UploadId to the active TUS upload URL. Each incoming TUS PATCH chunk translates directly into an S3 UploadPart call, streamed through memory into storage without intermediate disk caching.

If the client disconnects, it issues a TUS HEAD request asking for the current progress. Rilavek queries the confirmed S3 parts for that UploadId and returns the exact verified byte offset. The client resumes uploading, sending only the missing byte range.

Once every chunk arrives, Rilavek executes CompleteMultipartUpload to assemble the final object in your bucket. If an upload gets permanently abandoned, Rilavek cleans up partial multipart parts automatically. You do not need to configure bucket lifecycle rules or worry about hidden storage charges.

Best of all, your application code stays completely decoupled from these mechanics. Uppy manages the POST, PATCH, and HEAD requests on the frontend. Rilavek bridges those calls to your S3 storage in real time. Because the TUS resource binds to the Pipe, that same stream can also fan out to multiple backup buckets simultaneously.

How Does It Compare?

Raw S3 MultipartSelf-Hosted tusdRilavek
Resume after tab close❌ State lost✅ With GoldenRetriever✅ With GoldenRetriever
Resume after browser crash✅ With GoldenRetriever✅ With GoldenRetriever
Infrastructure to maintainNone (but fragile client logic)Host VM/Docker, storage backend, auth, scalingNone
Intermediate storageN/A (direct to S3)Local disk or custom S3 adapter on your serverNone (zero-disk streaming)
Max file sizeVaries by providerConfigurableUncapped by Rilavek (subject only to provider limits, like S3's 5 TB)
CORS / auth setupManualManualManaged per-Pipe
CostStorage provider PUT ratesServer runtime + storage costsFree tier, then $10/mo (100GB) + your storage provider costs

Troubleshooting

"Upload restarts from zero after closing the tab" Ensure GoldenRetriever is registered with Uppy. Without it, session state is lost when the browser tab unloads. Check the micro-insight section above for setup details.

"CORS errors on the TUS endpoint" Rilavek returns Access-Control-Allow-Origin: * on all endpoint responses, including error statuses. If you encounter CORS blocks in the browser, check whether an intermediate corporate proxy, VPN, or custom reverse proxy is stripping response headers before they reach the client.

"Upload stalls on very large files" Rilavek places no artificial ceiling on file sizes. If transfers stall on poor uplinks, tune down the chunkSize option in your TUS client config (such as 10 MB). Smaller chunks create shorter individual HTTP requests that recover faster from intermittent dropouts.

"Token expired mid-upload" Temporary upload tokens default to a 24-hour lifetime, which gives users plenty of time to pause and resume transfers. You can also customize this duration via the expiresInMinutes parameter when calling the token generation API. If you need uploads to resume days later, mint a fresh token or request a longer lifetime up to 7 days.

Stop Building Ad-Hoc Adapters

Do not spend weeks untangling ETag arrays, calculating byte offsets, or fighting S3 presigned URL expiration timers.

Rilavek provides a production-grade TUS endpoint that streams browser uploads straight into your cloud storage without intermediate servers. Your team gets to ship reliable upload UX without maintaining ingestion infrastructure.

Start for free → (10GB/month, no credit card required).

Related Guides

Technical Reference & Next Steps

Enjoyed this guide?

Share it with your network to help others scale their data pipelines.


Try Rilavek for free

Get started with 10GB/month of transfer. No credit card required.

Create Free Account

You Might Also Like