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, andservice_rolebypasses RLS. Useservice_roleonly 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).
| Method | Path | Purpose |
|---|---|---|
OPTIONS | /upload/resumable | Advertise tus capabilities (Tus-Version, Tus-Extension: creation,termination, Tus-Max-Size). |
POST | /upload/resumable | Creation. 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 againstUpload-Lengthat creation. - In-flight state lives in the per-project
storage.s3_multipart_uploads/storage.s3_multipart_upload_partstables (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 thanANVILBASE_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 everyANVILBASE_TUS_SWEEP_INTERVAL_SECS(default 1h) and is enabled by default (setANVILBASE_TUS_SWEEP_ENABLED=falseto disable). You should still callDELETEon an upload you abandon for prompt cleanup rather than waiting for the sweeper.
What’s next
- Working with Objects — the direct upload/download API.
- Bucket Management — per-bucket size/MIME limits (also enforced on resumable creation).
- Storage Overview — the storage model and security defaults.