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.
ETagmust be exposed in the bucket's CORS rules ("ExposeHeaders": ["etag"]) or JavaScript cannot read it and you cannot complete the upload. This is ininfra/r2/cors.jsonalready.- Every
signPartcall is a signing request, so authorise once when the upload starts and record theuploadIdagainst the user. Do not re-run your full authorisation logic per part, and do not let a client ask you to sign parts for anuploadIdit 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.tsandMAX_UPLOAD_BYTESis 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
uploadIdand 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.