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.
What matters most
- Once the declared count has arrived, the Assembly no longer waits for additional uploads. Reconcile its uploads list with the files you intended to send.
- Files can be up to 200 GB, but uploads must finish within eight hours of Assembly creation by default; processing has a separate deadline.
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, 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.
The Assembly stays in ASSEMBLY_UPLOADING until the expected uploads have finished, even when files that arrived early have already been processed. Once the declared count has arrived, it no longer waits for additional files. Late uploads can be rejected or ignored depending on the Assembly state, so set the exact intended count and reconcile the final uploads list with the files you meant to send.
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.
Reconcile the received files
Compare the Assembly uploads list with the intended files instead of assuming that successful completion proves every file arrived.
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.
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-streamCreate, 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. By default, uploading is limited to eight hours from Assembly creation, and processing to eight hours from upload completion. A workspace-specific processing limit can change the second window. An Assembly that passes its applicable deadline 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.
Separate processing deadline
The default processing window is eight hours from upload completion, separate from the upload deadline.
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.
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', { method: 'POST' })
if (!res.ok) throw new Error('Unable to authorize the upload')
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.
- By default, uploading is limited to eight hours from creation and processing to eight hours from upload completion. A workspace-specific processing limit can change that second deadline; passing either deadline returns ASSEMBLY_EXPIRED.
A practical approach
- 1
Create the Assembly with num_expected_upload_files set to the exact number of files the client will send.
- 2
Upload each file to the tus_url from the Assembly Status with a tus client rather than a plain POST.
- 3
Persist the upload URL on the client so a reload or a crash can resume instead of restarting.
- 4
Sign the Assembly creation request on your back end whenever the browser is the client.
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. By default, uploads have to finish within eight hours of Assembly creation, and processing within eight hours of upload completion. A workspace-specific processing limit can change the second window. After expiry 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?
Check whether num_expected_upload_files matched the intended file count. Once that count has arrived, the Assembly no longer waits for additional uploads. Compare its uploads list with your client-side file records, inspect failed upload requests, and count files before creating the next 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: by default, 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.