Skip to content

Presigned PUT or multipart: picking an upload strategy for R2

A single presigned PUT is right up to about 100 MB and restarts from zero when it fails. Above that, multipart is not an optimisation, it is the only thing that works.

Cloudflare R24 min readships at docs/solutions/r2/presigned-put-vs-multipart.md

Tags: r2 · s3 · uploads · presigned · multipart · resumable · browser

Your upload works. Someone uploads a 900 MB video over hotel wifi, it fails at 88%, and they start again. Then they hit the 5 GB ceiling on a single PUT and get an error that says nothing useful. Somewhere in between those two, a single presigned PUT stopped being the right tool.

Both mechanisms are worth understanding, because the boundary between them is about failure, not about size.

Presigned PUT: one request, all or nothing

The server mints a URL, the browser sends the whole file to it:

export async function getSignedUploadUrl(
  key: string,
  contentType: string,
  expiresIn = 900,
): Promise<SignedUpload> {
  const url = await getSignedUrl(
    s3(),
    new PutObjectCommand({ Bucket: bucket(), Key: key, ContentType: contentType }),
    { expiresIn },
  );

  return { url, method: "PUT", headers: { "content-type": contentType }, key, expiresIn };
}

The browser then does one request:

const xhr = new XMLHttpRequest();
xhr.open("PUT", signed.url);
xhr.setRequestHeader("content-type", signed.headers["content-type"]);
xhr.upload.onprogress = (event) => setProgress(event.loaded / event.total);
xhr.send(file);

XMLHttpRequest rather than fetch, because fetch still cannot report upload progress in any browser you can rely on.

This is the right choice for avatars, documents, photos, CSV imports: the overwhelming majority of what users upload. It is one round trip, the signature covers the whole thing, and there is nothing to reconcile if it fails.

Two constraints. The signed content-type is part of the signature: send a different one and R2 rejects the PUT with a signature mismatch, which is the single most common cause of "my upload returns 403". And a failed PUT is a total loss: there is no resume, no partial object, nothing to retry from.

Where the single PUT stops working

  • The hard ceiling. A single PUT to R2 caps at 5 GB, and in practice browsers, proxies and load balancers give up long before that.
  • The practical ceiling. Around 100 MB, the probability that a mobile connection survives the whole transfer stops being close to one. A 500 MB upload on a flaky connection is not slow, it is a coin flip you keep losing.
  • The expiry ceiling. Your presigned URL lives fifteen minutes. A large file on a slow uplink can outlive it, and the failure arrives at the end of a long wait.

Multipart: many parts, each retryable on its own

Multipart splits the object into parts that upload independently. R2 assembles them when you say so. The unit of failure becomes one part instead of the whole file.

The flow is three server calls around N browser PUTs:

import {
  AbortMultipartUploadCommand,
  CompleteMultipartUploadCommand,
  CreateMultipartUploadCommand,
  UploadPartCommand,
} from "@aws-sdk/client-s3";

/** 1. Start it. Returns an uploadId that identifies this attempt. */
export async function startMultipart(key: string, contentType: string) {
  const { UploadId } = await s3().send(
    new CreateMultipartUploadCommand({ Bucket: bucket(), Key: key, ContentType: contentType }),
  );
  if (!UploadId) throw new Error("R2 did not return an upload id");
  return { uploadId: UploadId, key };
}

/** 2. Sign one part. The browser PUTs to this and keeps the ETag it returns. */
export async function signPart(key: string, uploadId: string, partNumber: number) {
  return getSignedUrl(
    s3(),
    new UploadPartCommand({ Bucket: bucket(), Key: key, UploadId: uploadId, PartNumber: partNumber }),
    { expiresIn: 3600 },
  );
}

/** 3. Assemble. Parts must be listed in order, each with the ETag R2 returned. */
export async function completeMultipart(
  key: string,
  uploadId: string,
  parts: { PartNumber: number; ETag: string }[],
) {
  await s3().send(
    new CompleteMultipartUploadCommand({
      Bucket: bucket(),
      Key: key,
      UploadId: uploadId,
      MultipartUpload: { Parts: parts },
    }),
  );
}

In the browser, slice and upload, keeping each part's ETag:

const PART_SIZE = 16 * 1024 * 1024; // R2 requires every part except the last to be equal
const parts: { PartNumber: number; ETag: string }[] = [];

for (let i = 0; i * PART_SIZE < file.size; i++) {
  const blob = file.slice(i * PART_SIZE, (i + 1) * PART_SIZE);
  const url = await signPart(key, uploadId, i + 1);
  const response = await fetch(url, { method: "PUT", body: blob });
  const etag = response.headers.get("etag");
  if (!etag) throw new Error("no etag: check ExposeHeaders in the bucket CORS rules");
  parts.push({ PartNumber: i + 1, ETag: etag });
}

await completeMultipart(key, uploadId, parts);

Four details that bite:

  • R2 requires every part except the last to be exactly the same size. S3 only requires a 5 MB minimum. Code that works against S3 with variable part sizes fails on R2 at the complete step.
  • ETag must be exposed in the bucket's CORS rules ("ExposeHeaders": ["etag"]) or JavaScript cannot read it and you cannot complete the upload. This is in infra/r2/cors.json already.
  • Every signPart call is a signing request, so authorise once when the upload starts and record the uploadId against the user. Do not re-run your full authorisation logic per part, and do not let a client ask you to sign parts for an uploadId it did not start.
  • Abandoned uploads are billed. Parts of an incomplete multipart upload are stored, do not appear in a bucket listing, and are invisible until they show up as storage you cannot explain.

That last one is why infra/r2/lifecycle.json ships with:

{
  "ID": "abort-incomplete-multipart",
  "Status": "Enabled",
  "Filter": { "Prefix": "" },
  "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}

Apply it with bun run r2:lifecycle on every bucket, including ones you do not expect to use multipart on. Nothing else ever cleans this up.

Choosing

  • Under ~100 MB: presigned PUT. One request, no state, no reconciliation. This is the default in src/lib/storage/index.ts and MAX_UPLOAD_BYTES is 25 MB.
  • Over ~100 MB, or any file where a mid-upload failure is unacceptable: multipart.
  • Genuinely resumable across a browser refresh: neither, on its own. You need to persist the uploadId and the completed part list somewhere the client can recover them, which means a database row and a resume endpoint. Reach for a library at that point rather than writing it a third time.

Raising MAX_UPLOAD_BYTES without moving to multipart just moves the failure later, into a slower and more frustrating place.