Tanstack Start

How it works

The browser talks to the bucket. The API only signs and authorizes.

s3.delivery is built so that file bytes never pass through any API — not yours, not ours. The system has three parts with clearly split jobs.

PartRole
Worker APISecurity guard and signer. Checks auth and quota, enforces constraints, mints presigned URLs, tracks metadata. Never touches file bytes.
Database (D1)Source of truth: ownership, status, visibility, and size. Access is always decided here — never from the bucket path.
Bucket (S3-compatible)Stores the actual bytes. Browsers upload and download directly.

Upload flow

Browser ──ask──▶ Worker API ──▶ D1 (pending row + presigned URL)
Browser ════════ PUT bytes ════════▶ Bucket        (the cheap, direct path)
Browser ──done─▶ Worker API ──▶ verify object exists + mark ready
  1. The browser (via your route handler) asks the API for an upload URL — createUpload with the filename, content type, and size.
  2. The API checks the API key, enforces your route constraints and the plan limits, writes a pending row to the database, and returns a presigned PUT URL (one URL for small files, a set of part URLs for large ones).
  3. The browser PUTs the bytes straight to the bucket. Nothing flows through the API.
  4. The browser calls complete. The API confirms the object really exists in the bucket, records its real size and checksum, and flips the row to ready.

Because completion is a normal request to your own server, the onUploadComplete callback runs inline — no webhook endpoint, no public callback URL, no dev tunnel.

Multipart for large files

Files at or above 100 MB switch to multipart automatically. The API initiates the multipart upload and returns a presigned URL per 16 MB part; the browser uploads parts concurrently (6 at a time by default) and the API stitches them together on complete. You do not configure any of this — see Large files & multipart.

Download flow

  1. The browser asks for a file — get(fileId), or simply navigates to the stable /f/:id URL.
  2. The API checks the database: the requester is the owner, or the file is public / unlisted.
  3. The API signs a short-lived GET URL locally (no extra round-trip to storage) and returns it, or redirects /f/:id to it with a 302.
  4. The browser downloads directly from the bucket.

Signed download URLs are short-lived: 15 minutes for private files and 24 hours for public/unlisted ones. The /f/:id URL itself is stable and safe to store — it re-signs on every request.

Why it is cheap and fast

  • No proxy bandwidth. Bytes never pass through an API, so there is no egress to pay for on a server and no CPU/memory bottleneck to rate-limit you.
  • Storage is the only real cost. Object storage is billed per GB-month — pennies per GB.
  • The API stays tiny. Each request is a small signing plus a database read or write, so latency stays low and throughput scales with your bucket, not a proxy.
  • Parallelism by default. Batches upload several files at once and large files upload parts concurrently, so a slow file never stalls the rest.

You can measure it yourself on the Speed Test page in the dashboard, or with the bundled benchmark that compares raw bucket throughput against the full wrapped SDK path.

Security model

  • Bucket credentials live only on the server. The browser never sees them — only short-lived presigned URLs.
  • The database is always consulted before signing a download. Private files require the owner; a request for someone else's private file returns 404 (we do not leak that it exists).
  • Object keys are not user-controlled. Keys are users/{userId}/files/{fileId}/{safeName}, where fileId is a random, unguessable id and the filename is sanitized. Access is decided by the database row, never by the path.
  • Keys are stored hashed. API keys are kept as a SHA-256 hash; the plaintext is shown exactly once at creation. See API keys.
  • Abandoned uploads are cleaned up. A scheduled job sweeps pending rows older than an hour, deleting any orphaned object and marking the row deleted, so a reserved-but-never-finished upload never lingers or counts against quota.

File lifecycle

A file row moves through three states:

StatusMeaning
pendingReserved by createUpload; bytes may still be in flight. Not yet downloadable.
readycomplete confirmed the object exists. Downloadable, counts toward usage.
deletedSoft-deleted by delete or swept by the cleanup job. The object is removed from the bucket.

Only ready files appear in list and count toward your storage usage.

On this page