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