Uploads and frontend integration

# How to accept large uploads that survive a dropped connection

Accept multi-gigabyte uploads over tus, continue them after a dropped connection, and keep the Assembly alive long enough to finish.

Published August 13, 2026

## Key takeaways

* Files included in the multipart Assembly creation request cannot be resumed, so any interruption fails the whole Assembly.
* Uploading over tus lets a client continue from the byte offset the server already holds instead of starting again.
* Create the Assembly first with num\_expected\_upload\_files, then upload the files to the tus\_url that it returns.

A large upload is not a request that occasionally fails. It is a transfer that will be interrupted, and the only real decision is whether an interruption costs the user the remaining bytes or all of them. Everything else in the design follows from that choice.

## In this guide

1. [Two upload paths, and only one of them resumes](#resumable-uploads-for-large-files-section-1)
2. [Declare how many files are coming](#resumable-uploads-for-large-files-section-2)
3. [Resuming is an offset, not a retry](#resumable-uploads-for-large-files-section-3)
4. [The transfer resumes, the Assembly still expires](#resumable-uploads-for-large-files-section-4)
5. [Attach the metadata each file needs](#resumable-uploads-for-large-files-section-5)
6. [Sign the request when the browser is the client](#resumable-uploads-for-large-files-section-6)

## What matters most

* Uploads beyond the declared count are dropped silently, so an off-by-one in that number loses files without an error.
* Files can be up to 200 GB, but the Assembly still expires eight hours after it was created.

## Two upload paths, and only one of them resumes

Transloadit accepts files in two ways, and the difference between them only shows up on a bad connection. Files can be attached to the `multipart/form-data` POST that creates the Assembly, which is simple and fine for a profile picture. Any interruption to that request fails the upload and the Assembly with it, and the client has no way to continue: it can only send everything again.

The other path uses [tus⁠](https://tus.io/), an open protocol for resumable uploads over HTTP with client implementations in most languages. Transloadit runs a tus server, and a client that speaks the protocol can pause, lose its connection, and pick up from the byte the server last acknowledged. For anything measured in hundreds of megabytes, that is the difference between an upload that eventually completes and one that never does.

### Multipart

One request that carries the files. An interruption fails both the upload and the Assembly.

### tus

A separate transfer per file that can be continued from the last acknowledged byte.

### Already handled for you

Uppy and the back-end SDKs use tus underneath, so most integrations get this without extra work.

## Declare how many files are coming

A resumable upload inverts the usual order: the Assembly is created before any bytes exist. The creation request carries `params` as normal, plus a `num_expected_upload_files` field stating how many files will follow, and no file content at all. The response is an ordinary Assembly Status with two additions that matter here, `tus_url` for where the uploads go and the trio of `expected_tus_uploads`, `started_tus_uploads`, and `finished_tus_uploads` for tracking them.

That declared count is enforced more strictly than it first appears. The Assembly stays in `ASSEMBLY_UPLOADING` until every expected upload has finished, even when files that arrived early have already been processed. Uploads offered beyond the declared count are dropped without an error, which makes a wrong count one of the harder bugs to notice: the Assembly succeeds, and a file is simply missing from the results.

Create the Assembly before any bytes are sent

```
POST /assemblies HTTP/1.1
Host: api2.transloadit.com
Content-Type: multipart/form-data; boundary=---xyz

-----xyz
Content-Disposition: form-data; name="params"

{"auth":{"key":"YOUR_KEY"},
 "template_id":"YOUR_TEMPLATE_ID"}
-----xyz
Content-Disposition: form-data;
  name="num_expected_upload_files"

2
-----xyz--
```

### num\_expected\_upload\_files

Set it to the exact number of files the client will send, and count the files before creating the Assembly.

### tus\_url

The upload endpoint for this Assembly, returned in the Assembly Status rather than hard-coded.

### Extra files disappear

Anything past the declared count is dropped quietly, so the count is worth asserting on.

## Resuming is an offset, not a retry

Each upload begins with a POST to the `tus_url` that creates a resource rather than sending data. The request carries three pieces of metadata, `assembly_url`, `filename`, and `fieldname`, and the server replies with an upload URL in the `Location` header. The bytes then go to that URL in one or more PATCH requests, and once the last one lands the file is fed into the Assembly without any further call.

Recovery uses the same URL. A HEAD request returns an `Upload-Offset` header with the number of bytes the server actually holds, and the client resumes with a PATCH starting from exactly that offset. This is why a retry and a resume are not the same operation: a retry sends the file again from zero, while a resume asks the server what it already has and sends only the difference.

Ask for the offset, then send only the remainder

```
HEAD /resumable/files/136058f2ef4d HTTP/1.1
Host: api2-freja.transloadit.com
Tus-Resumable: 1.0.0

HTTP/1.1 204 No Content
Upload-Offset: 3000
Upload-Length: 10000

PATCH /resumable/files/136058f2ef4d HTTP/1.1
Host: api2-freja.transloadit.com
Tus-Resumable: 1.0.0
Upload-Offset: 3000
Content-Length: 7000
Content-Type: application/offset+octet-stream
```

### Create, then transfer

The first POST establishes the upload URL; the PATCH requests carry the actual content.

### Upload-Offset

The server states how much it received, so the client never has to guess where to continue.

### Keep the upload URL

Resuming after a page reload requires that URL, so persist it rather than holding it in memory.

## The transfer resumes, the Assembly still expires

Resumability is often read as an unlimited grace period, and it is not. The Assembly was created at the start, and its clock is already running: uploading is limited to eight hours, processing to a further eight, and the two together are capped at sixteen hours from creation. An Assembly that passes those limits returns `ASSEMBLY_EXPIRED`, and the partial upload behind it stops being useful.

This matters most for the workloads that need resumability in the first place. A user who pauses a large upload overnight will come back to an Assembly that no longer exists, so the client needs to detect that case and create a new one rather than retrying into a dead URL. Treating expiry as an expected outcome, instead of an error to log, keeps that recovery path honest.

### Eight hours to upload

Measured from Assembly creation, not from the moment the transfer last made progress.

### Sixteen-hour ceiling

Uploading and processing combined cannot exceed sixteen hours from creation.

### Plan for a restart

Detect an expired Assembly and create a fresh one instead of retrying the old upload URL.

## Attach the metadata each file needs

Beyond the three required values, any additional metadata sent with an upload becomes available as an Assembly Variable under `file.user_meta`, so a key sent as `owner` is read as `${file.user_meta.owner}`. That distinction is worth internalising early, because `fields` is shared by every file in the Assembly while user metadata belongs to one file. A batch where each file needs its own destination path, owner, or category wants the latter, and trying to express it through shared fields ends in a Template that cannot tell the files apart.

For branching on content rather than on what the client claimed, prefer `${file.mime}` and match families such as `image/*` or `video/*`. A client-supplied filename or category is a hint, and treating it as a fact is how an executable ends up on a path that assumed images. The broad `${file.type}` category exists as well, but MIME matching is the more precise of the two.

### Required three

Every upload needs assembly\_url, filename, and fieldname in its tus metadata.

### Per file or per Assembly

User metadata belongs to one file; fields are shared by all of them in the same run.

### Branch on MIME

Match ${file.mime} rather than trusting an extension or a client-supplied category.

## Sign the request when the browser is the client

An upload that starts in a browser means the Assembly Instructions are being submitted from an environment you do not control. Signature Authentication closes that gap: your back end signs the params with the Auth Secret, adds an `auth.expires` timestamp in the near future, and hands the result to the front end. Turning on the requirement in Workspace Settings makes the API reject anything unsigned for the account.

The useful part is that your server decides what it is willing to sign. It can refuse anonymous users, cap the Template a request may invoke, or narrow the parameters before signing, all with ordinary application logic. The Auth Secret never leaves the back end, and an intercepted signature is only good until the expiry it was issued with.

Fetch the signature from your own back end

```
const uppy = new Uppy().use(Transloadit, {
  waitForEncoding: true,
  assemblyOptions: async () => {
    // Your back end signs with the Auth Secret
    const res = await fetch('/api/tl-signature')
    const { params, signature } = await res.json()
    return { params, signature }
  },
})
```

### Sign on the server

The Auth Secret stays on the back end and never reaches a browser bundle.

### auth.expires

A near-future timestamp that limits how long an issued signature remains usable.

### Require it

Workspace Settings can reject every unsigned request for the account outright.

## Technical details worth knowing

* The Assembly is created by a multipart POST carrying params and num\_expected\_upload\_files but no file content, and the response includes tus\_url, expected\_tus\_uploads, started\_tus\_uploads, and finished\_tus\_uploads.
* An Assembly stays in the ASSEMBLY\_UPLOADING state until every declared tus upload has finished, even when some of the files it already received have been processed.
* Each tus upload starts with a POST to tus\_url carrying assembly\_url, filename, and fieldname as metadata, and the server answers with the upload URL in the Location header.
* Resuming is a HEAD request to that upload URL, which reports the received byte count in the Upload-Offset header, followed by a PATCH that sends the remainder from exactly that offset.
* Extra metadata sent with an upload becomes an Assembly Variable under file.user\_meta, which is per file, unlike fields, which is shared by every file in the same Assembly.
* Uploading is limited to eight hours and processing to a further eight, capped at sixteen hours from creation, after which the Assembly returns ASSEMBLY\_EXPIRED.

## A practical approach

1. 1\
   Create the Assembly with num\_expected\_upload\_files set to the exact number of files the client will send.
2. 2\
   Upload each file to the tus\_url from the Assembly Status with a tus client rather than a plain POST.
3. 3\
   Persist the upload URL on the client so a reload or a crash can resume instead of restarting.
4. 4\
   Sign the Assembly creation request on your back end whenever the browser is the client.

A four-stage media workflow

## When Transloadit is useful

Use Uppy with the Transloadit plugin in the browser, or any tus client elsewhere, and let /upload/handle receive the files. Create the Assembly with num\_expected\_upload\_files first, then send each file to the tus\_url returned in the Assembly Status.

## Architecture boundary

Resumability recovers an interrupted transfer, not a forgotten one. Uploads have to finish within eight hours of the Assembly being created, and processing within a further eight, with a sixteen-hour ceiling from creation. After that the Assembly returns ASSEMBLY\_EXPIRED and the bytes already received are no longer usable.

## Frequently asked questions

### Do I have to implement the tus protocol myself?

Usually not. Uppy and the back-end SDKs use tus by default, so a normal integration already gets resumable uploads. Implementing the protocol directly is for building an SDK, or for using a language where no Transloadit SDK exists.

### Why did some of my files never show up in the results?

The likely cause is a `num_expected_upload_files` value lower than the number of files actually sent. Uploads past the declared count are dropped silently, so the Assembly completes successfully with files missing. Count the files before creating the Assembly.

### What happens if the user closes the tab during an upload?

The transfer can be resumed as long as the client kept the upload URL and the Assembly has not expired. Persist that URL outside page memory, then send a HEAD request on return to learn the offset and continue from there.

### How large a file can I upload?

Files of up to 200 GB are supported, and higher limits can be arranged. The practical constraint is usually time rather than size: the upload has eight hours from Assembly creation, which a slow connection can exhaust before a very large file finishes.

### Should per-file information go in fields or in metadata?

Use tus upload metadata when the value belongs to one file, since it arrives as an Assembly Variable under `file.user_meta`. Use `fields` only for values shared by every file in the Assembly, such as a customer identifier that applies to the whole batch.

## Build the workflow

Move from the concept to a tested Assembly with Robot documentation and working demos.

### Relevant Robots

* [/upload/handle](/docs/robots/upload-handle.md)
* [Read the API documentation](/docs.md)
* [Explore working demos](/demos.md)
* [Create a free workspace](/c/signup/)

Uploads and frontend integration

## Continue with related guides

* [How to serve responsive images from one URL](/guides/serve-responsive-images-from-one-url.md)\
  Derive every image size from one canonical URL, cache the results at the edge, and keep the encoding bill flat while traffic grows.
* [Media automation: from upload to reliable output](/guides/media-automation.md)\
  Automate repeatable media intake, transformation, validation, and export while preserving observability and control.
* [File uploads, image optimization, video encoding, and your own S3 bucket](/guides/file-uploads-image-optimization-s3.md)\
  Combine resumable browser uploads, image optimization, video encoding, customer-owned Amazon S3 storage, and on-demand image delivery without operating separate media services.
* [File Upload API guide: architecture, security, and provider selection](/guides/file-upload-api-guide.md)\
  Choose and implement a File Upload API by comparing architecture, resumability, direct-to-cloud transfer, security, storage boundaries, and providers.
* [HTML video in production: 10 practical checks](/guides/html-video-production-checklist.md)\
  Ten production checks for HTML video, from source selection and captions to poster images and preprocessing.
* [Three best practices for CSS banner images](/guides/css-banner-image-best-practices.md)\
  Three durable practices for sharp, responsive CSS banner images without unreadable text or accidental crops.
