Skip to content

Resumable Uploads

Direct uploads (PUT/POST .../object/<bucket>/<path>) are buffered in memory and capped at 50 MB per request. For larger files, AnvilBase implements the TUS 1.0.0 resumable upload protocol, backed by an S3 multipart upload, so big objects upload in resumable chunks and survive interrupted connections.

This is purely additive — the direct upload path is unchanged. Use resumable uploads when a file is large or the connection is unreliable; keep using the direct path for small files.

Supabase compatibility

The endpoint matches what @supabase/storage-js drives with tus-js-client. The SDK’s resumable upload works drop-in — it sends bucketName + objectName in Upload-Metadata and uploads in 6 MB chunks (which satisfy the part-size rule below).

The creation response returns an absolute resumable URL in the Location header (it includes the full /v1/storage/<project> prefix), so tus-js-client resolves it correctly against the creation URL with no onBeforeRequest rewrite required.

import * as tus from 'tus-js-client'
const upload = new tus.Upload(file, {
endpoint: `${BASE}/upload/resumable`,
// S3 requires every part except the last to be >= 5 MiB; use 6 MB.
chunkSize: 6 * 1024 * 1024,
headers: { authorization: `Bearer ${SERVICE_ROLE_KEY}`, apikey: SERVICE_ROLE_KEY },
metadata: { bucketName: 'big', objectName: 'videos/clip.mp4', contentType: file.type },
onSuccess: () => console.log('done'),
})
upload.start()

The same access rules as a normal write apply: the request is authenticated and tenant-isolated, owner-prefix RLS is enforced for user-… buckets, and service_role bypasses RLS. Use service_role only in trusted server code.

Endpoints

Base path: /v1/storage/<project_id>/upload/resumable (and the SDK-compat alias /v1/<project_id>/storage/v1/upload/resumable).

MethodPathPurpose
OPTIONS/upload/resumableAdvertise tus capabilities (Tus-Version, Tus-Extension: creation,termination, Tus-Max-Size).
POST/upload/resumableCreation. Read Upload-Length + Upload-Metadata, open the S3 multipart upload, return 201 + Location.
HEAD/upload/resumable/<id>Report the current Upload-Offset and Upload-Length.
PATCH/upload/resumable/<id>Append the next chunk at Upload-Offset; on the final chunk the object is finalized.
DELETE/upload/resumable/<id>Termination. Abort the multipart upload and discard state.

Creation (POST)

Send the total size in Upload-Length (bytes) and the target in Upload-Metadata — a comma-separated list of key <base64-value> pairs. The two required keys are bucketName (the logical bucket) and objectName (the key within it); an optional contentType sets the stored type, and an optional cacheControl metadata pair is stored on the object and re-emitted on download (same as the single-shot path).

The response is 201 Created with a Location pointing at the new upload resource (/upload/resumable/<id>) and Tus-Resumable: 1.0.0. The bucket’s file_size_limit (checked against Upload-Length) and allowed_mime_types, plus the project storage quota, are enforced up front at creation.

By default a resumable upload will not overwrite an existing object — creation returns 409 The resource already exists (with Tus-Resumable: 1.0.0). Send x-upsert: true as a request header (or an upsert Upload-Metadata pair) to overwrite.

Uploading chunks (PATCH)

Each PATCH carries Content-Type: application/offset+octet-stream and an Upload-Offset that must equal the server’s current offset (a mismatch returns 409 Conflict — re-HEAD to resync). The body is the next chunk. The server responds 204 No Content with the new Upload-Offset. When the offset reaches Upload-Length, the object is finalized and lands in the bucket exactly like a direct upload (downloadable byte-for-byte, with the same content-type handling).

Resuming

After an interruption, HEAD the upload to read Upload-Offset, then continue PATCHing from there. tus-js-client does this automatically.

Chunk-size requirement (≥ 6 MB)

Each PATCH chunk maps 1:1 to one S3 multipart part. S3 requires every part except the last to be at least 5 MiB. So:

  • A non-final chunk smaller than 5 MiB is rejected with a clear 413.
  • The last chunk may be any size (it’s the multipart “tail”).
  • A whole small file uploaded as a single chunk is fine — it’s the last part.

Use 6 MB chunks (the supabase storage-js default) and you never hit this. v1 does not buffer sub-5 MB chunks across requests — send chunks ≥ 6 MB, exactly as Supabase documents.

Limits & lifecycle

  • Max object size: advertised via Tus-Max-Size (50 GB) and enforced against Upload-Length at creation.
  • In-flight state lives in the per-project storage.s3_multipart_uploads / storage.s3_multipart_upload_parts tables (mirroring Supabase). Completing or terminating an upload cleans them up.
  • Abandoned uploads: an interrupted upload that’s never resumed or terminated leaves an in-flight S3 multipart upload + a DB row. A default-on background sweeper aborts resumable uploads left untouched (updated_at) for longer than ANVILBASE_TUS_STALE_HOURS (default 24h) and reconciles DB-orphaned S3 multipart uploads, reclaiming disk that is otherwise invisible to tenant listings and quota. It runs every ANVILBASE_TUS_SWEEP_INTERVAL_SECS (default 1h) and is enabled by default (set ANVILBASE_TUS_SWEEP_ENABLED=false to disable). You should still call DELETE on an upload you abandon for prompt cleanup rather than waiting for the sweeper.

What’s next