# 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.

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:

```ts
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:

```ts
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:

```ts
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`:

```ts
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:

```json
{
  "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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
