Uploads and delivery

Design browser and multipart uploads

Choose Worker-proxied, presigned, or multipart upload flows based on file size, trust, and recovery needs.

There are three ways to get a file into R2. File size picks between them.

Small trusted uploads go through a Worker. The Worker authenticates the user, validates the metadata, and streams the body to R2. One request, one put, done. This is the right answer for avatars and documents.

Direct browser uploads use a presigned URL. Your server signs a short-lived URL for one specific key, and the browser sends the file straight to R2. The Worker never touches the bytes, so you save transfer. In exchange you need two things: authorization on the signing endpoint, so only the right user gets a URL for that key, and CORS on the bucket, so the browser’s PUT is allowed from your origin.

For large files, use multipart upload. It splits one big object into parts that upload independently, then joins them into one object at the end.

You start the upload once, then resume it in every request that carries a part:

const upload = await env.FILES.createMultipartUpload('videos/launch-demo.mp4')

// each part arrives in its own request, resumed by uploadId
const resumed = env.FILES.resumeMultipartUpload(
  'videos/launch-demo.mp4',
  upload.uploadId
)
const part = await resumed.uploadPart(partNumber, request.body)
// part = { partNumber: 1, etag: "..." }

Every part returns an etag. The client keeps them all, and completion assembles the object:

await resumed.complete([
  { partNumber: 1, etag: 'bce6bf66aeb76c7040fdd5f4eccb78e6' },
  { partNumber: 2, etag: '8165449fc15bbf43d3b674595cbcc406' },
])

The rules on parts are strict. Every part except the last must be at least 5 MiB. All parts except the last must be the same size. One object can have at most 10,000 parts. Get the sizing wrong and complete rejects the upload.

What you get in return is resumability. A failed part retries alone. A dropped connection resumes from the last confirmed part number instead of restarting a 2 GB transfer.

The cleanup nobody does

Abandoned multipart uploads keep their parts around. You pay for that storage even though no object ever appears. Call abortMultipartUpload when a user cancels. R2 also aborts incomplete multipart uploads after seven days by default, so parts don’t linger forever, but explicit cleanup is cheaper.

One rule applies to all three flows: never trust the content type the browser sends. The client said image/png. That’s a claim, not a fact. Check it server-side before you store it as metadata.

Try this on paper: draw a small avatar upload and a resumable large-video upload, including the server authorization step. If your diagram has no arrow labeled “server checks who is asking”, the design has a hole.

Lesson completed