October 24, 2026
Direct-to-R2 Uploads: Presigned PUTs, Multipart, and 1000 Files at Once
A guest at a wedding opens EventAssets on their phone, selects 300 photos and a couple of video clips, and hits upload. That is roughly 4โฆ

By Tristan Trommer
8 min read
A guest at a wedding opens EventAssets on their phone, selects 300 photos and a couple of video clips, and hits upload. That is roughly 4 GB, across 302 HTTP requests that must not fail, from a device on hotel wifi.
None of those bytes go through my API, because my API is a Cloudflare Worker with a 128 MB memory limit and no business being a file server.
This post is the upload path: how the Worker hands out permission to write to R2 without ever seeing the data, and what the client has to do with that permission.
Presigning from a Worker
The standard way to grant scoped, temporary write access to S3-compatible storage is a presigned URL โ a normal URL with a SigV4 signature in the query string that encodes the bucket, key, method, expiry and any headers the request must carry.
The AWS SDK can produce these. It is also large, Node-shaped, and unnecessary when all you need is the signing algorithm. aws4fetch is about 4 KB, built for Workers, and does exactly one thing:
export class R2Service {
private readonly awsClient: AwsClient;
constructor(
private readonly bucket: R2Bucket,
private readonly credentials: R2Credentials
) {
this.awsClient = new AwsClient({
accessKeyId: credentials.ACCESS_KEY_ID,
secretAccessKey: credentials.SECRET_ACCESS_KEY
});
}
private async signedUrl(
key: string,
options: {
method: 'GET' | 'PUT';
ttlSeconds: number;
query?: Record<string, string>;
headers?: Record<string, string>;
}
): Promise<string> {
const { ACCOUNT_ID, BUCKET_NAME } = this.credentials;
const url = new URL(
`https://${BUCKET_NAME}.${ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`
);
url.searchParams.set('X-Amz-Expires', options.ttlSeconds.toString());
for (const [name, value] of Object.entries(options.query ?? {})) {
url.searchParams.set(name, value);
}
const signed = await this.awsClient.sign(
new Request(url, { method: options.method, headers: options.headers }),
{ aws: { signQuery: true, allHeaders: true } }
);
return signed.url;
}
}export class R2Service {
private readonly awsClient: AwsClient;
constructor(
private readonly bucket: R2Bucket,
private readonly credentials: R2Credentials
) {
this.awsClient = new AwsClient({
accessKeyId: credentials.ACCESS_KEY_ID,
secretAccessKey: credentials.SECRET_ACCESS_KEY
});
}
private async signedUrl(
key: string,
options: {
method: 'GET' | 'PUT';
ttlSeconds: number;
query?: Record<string, string>;
headers?: Record<string, string>;
}
): Promise<string> {
const { ACCOUNT_ID, BUCKET_NAME } = this.credentials;
const url = new URL(
`https://${BUCKET_NAME}.${ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`
);
url.searchParams.set('X-Amz-Expires', options.ttlSeconds.toString());
for (const [name, value] of Object.entries(options.query ?? {})) {
url.searchParams.set(name, value);
}
const signed = await this.awsClient.sign(
new Request(url, { method: options.method, headers: options.headers }),
{ aws: { signQuery: true, allHeaders: true } }
);
return signed.url;
}
}signQuery: true puts the signature in the query string instead of an Authorization header, which is what makes the URL usable by a browser that will not be setting headers of its own. allHeaders: true means every header passed here becomes part of the signature โ and therefore becomes mandatory for the client.
That matters for PUTs:
const GET_URL_TTL_SECONDS = 2 * 60 * 60;
const PUT_URL_TTL_SECONDS = 20 * 60;
createPresignedUrlPut(
key: string,
contentType: ContentType,
fileSize: number
): Promise<string> {
return this.signedUrl(key, {
method: 'PUT',
ttlSeconds: PUT_URL_TTL_SECONDS,
headers: {
'Content-Length': fileSize.toString(),
'Content-Type': CONTENT_TYPE_TO_MIME[contentType]
}
});const GET_URL_TTL_SECONDS = 2 * 60 * 60;
const PUT_URL_TTL_SECONDS = 20 * 60;
createPresignedUrlPut(
key: string,
contentType: ContentType,
fileSize: number
): Promise<string> {
return this.signedUrl(key, {
method: 'PUT',
ttlSeconds: PUT_URL_TTL_SECONDS,
headers: {
'Content-Length': fileSize.toString(),
'Content-Type': CONTENT_TYPE_TO_MIME[contentType]
}
});Signing Content-Length binds the URL to a specific byte count. A client that received a URL for a 2 MB file cannot use it to write 2 GB โ the signature will not match. It is not a complete defence (the client picked the number in the first place), but it does mean a leaked URL cannot be repurposed for something bigger than what was authorised.
Twenty minutes for writes, two hours for reads. Write URLs should live exactly as long as a slow upload on bad wifi, and not a minute longer.
Two APIs for one bucket
Here is the wrinkle that shapes the whole service, and it took me by surprise: presigning cannot start or finish a multipart upload.
A multipart upload needs a CreateMultipartUpload call to get an upload ID, then a signed PUT per part, then a CompleteMultipartUpload carrying every part number and ETag. The middle step presigns fine. The first and last do not โ they are API calls with request bodies and parsed responses, not a single authorised write to a key.
So R2Service talks to the same bucket through two entirely different mechanisms:
async createMultipartUpload(
key: string,
contentType: ContentType
): Promise<MultipartUploadInfo> {
const multipartUpload = await this.bucket.createMultipartUpload(key, {
httpMetadata: { contentType: CONTENT_TYPE_TO_MIME[contentType] }
});
return { key: multipartUpload.key, uploadId: multipartUpload.uploadId };
}
async completeMultipartUpload(
key: string,
uploadId: string,
parts: R2UploadedPart[]
): Promise<void> {
await this.bucket.resumeMultipartUpload(key, uploadId).complete(parts);
}async createMultipartUpload(
key: string,
contentType: ContentType
): Promise<MultipartUploadInfo> {
const multipartUpload = await this.bucket.createMultipartUpload(key, {
httpMetadata: { contentType: CONTENT_TYPE_TO_MIME[contentType] }
});
return { key: multipartUpload.key, uploadId: multipartUpload.uploadId };
}
async completeMultipartUpload(
key: string,
uploadId: string,
parts: R2UploadedPart[]
): Promise<void> {
await this.bucket.resumeMultipartUpload(key, uploadId).complete(parts);
}this.bucket is the Workers R2 binding โ no signing, no HTTP, a direct capability handed to the Worker by the runtime. this.awsClient is SigV4 over the public S3 endpoint. The class holds both because neither alone covers the surface.
This is worth internalising before you design your own upload flow: the binding and the S3 API are not interchangeable, and which one you need depends on the operation, not on preference.
The fork at 100 MB
export const MULTIPART_THRESHOLD = 100 * 1024 * 1024; // 100 MB
return await Promise.all(
preparedFiles.map(async file => {
if (file.fileSize >= MULTIPART_THRESHOLD) {
const multipartInfo = await r2Service.createMultipartUpload(
file.key,
file.contentType
);
return {
type: 'multipart' as const,
upload_id: multipartInfo.uploadId,
key: multipartInfo.key
};
} else {
const url = await r2Service.createPresignedUrlPut(
file.key,
file.contentType,
file.fileSize
);
return { type: 'default' as const, url, key: file.key };
}
})
);export const MULTIPART_THRESHOLD = 100 * 1024 * 1024; // 100 MB
return await Promise.all(
preparedFiles.map(async file => {
if (file.fileSize >= MULTIPART_THRESHOLD) {
const multipartInfo = await r2Service.createMultipartUpload(
file.key,
file.contentType
);
return {
type: 'multipart' as const,
upload_id: multipartInfo.uploadId,
key: multipartInfo.key
};
} else {
const url = await r2Service.createPresignedUrlPut(
file.key,
file.contentType,
file.fileSize
);
return { type: 'default' as const, url, key: file.key };
}
})
);Below the threshold, one signed PUT. Above it, a multipart session.
The two branches return different shapes, discriminated by type, and that discriminant is what the client switches on. A photo gets a URL. A video gets an upload ID and has to come back for part URLs.
100 MB is a judgement call rather than a law. Small files gain nothing from multipart and pay an extra two round trips for the privilege. Large files gain retryability per 10 MB chunk instead of per gigabyte โ which on a phone on hotel wifi is the difference between a video that eventually uploads and one that never does.
Here is the whole flow:
Everything before the par block is authorisation. Everything inside it is the client talking to R2 directly. The API only reappears to complete the multipart session and to answer status polls.
Rows before URLs
Ordering inside initiateUpload is not incidental:
const preparedFiles = request.files.map(file => {
const contentType = MIME_TO_CONTENT_TYPE[file.content_type];
if (!contentType) {
throw new Error(`Invalid content type: ${file.content_type}`);
}
return {
key: session.createKey(contentType),
contentType,
fileSize: file.file_size
};
});
await session.preUpload(preparedFiles);const preparedFiles = request.files.map(file => {
const contentType = MIME_TO_CONTENT_TYPE[file.content_type];
if (!contentType) {
throw new Error(`Invalid content type: ${file.content_type}`);
}
return {
key: session.createKey(contentType),
contentType,
fileSize: file.file_size
};
});
await session.preUpload(preparedFiles);preUpload is where the target writes its pending rows and enforces quota. It runs before any URL is generated, which means no client ever holds write access to a key the database does not already expect. That expectation is the foundation of the whole validation pipeline โ an object arriving at a key with no pending row gets deleted on sight.
The contentType recheck has a comment explaining why it is not redundant:
// The route body is validated per target against the same
// `contentTypes`/`fileSize` constants, so only the lookup itself is
// rechecked: `MIME_TO_CONTENT_TYPE` is cast to a total record, and
// a mime listed on a target but missing from `CONTENT_TYPE_CONFIG`
// would otherwise silently produce a `<uuid>.undefined` key.// The route body is validated per target against the same
// `contentTypes`/`fileSize` constants, so only the lookup itself is
// rechecked: `MIME_TO_CONTENT_TYPE` is cast to a total record, and
// a mime listed on a target but missing from `CONTENT_TYPE_CONFIG`
// would otherwise silently produce a `<uuid>.undefined` key.The cast that makes the lookup convenient also makes it lie about totality. Without the guard, adding a MIME type to a target's allowlist and forgetting to add it to the central config produces object keys ending in .undefined โ valid keys, silently wrong, discovered much later.
What the client has to do
The server side is the easy half. The client is orchestrating hundreds of concurrent network operations on a device that may lose connectivity at any point, and the constants are where the design lives:
export const MULTIPART_CHUNK_SIZE = 10 * 1024 * 1024;
export const MAX_PARALLEL_PARTS = 3;
export const MAX_PARALLEL_FILES = 4;
export const UPLOAD_INITIALIZATION_BATCH_SIZE = 20;
export const MAX_RETRY_ATTEMPTS = 3;
export const DEFAULT_UPLOAD_POLL_INTERVAL_MS = 5_000;
export const DEFAULT_UPLOAD_PENDING_TIMEOUT_MS = 5 * 60 * 1_000;export const MULTIPART_CHUNK_SIZE = 10 * 1024 * 1024;
export const MAX_PARALLEL_PARTS = 3;
export const MAX_PARALLEL_FILES = 4;
export const UPLOAD_INITIALIZATION_BATCH_SIZE = 20;
export const MAX_RETRY_ATTEMPTS = 3;
export const DEFAULT_UPLOAD_POLL_INTERVAL_MS = 5_000;
export const DEFAULT_UPLOAD_PENDING_TIMEOUT_MS = 5 * 60 * 1_000;Initialization is batched at 20. The API accepts up to 1000 files per request, but asking for 1000 URLs at once means one enormous request, 1000 rows written before anything uploads, and a twenty-minute PUT expiry ticking on URLs the client will not use for another ten minutes. Twenty at a time keeps URLs fresh and gets the first bytes moving almost immediately.
Four files upload at a time, three parts within each. Twelve concurrent connections at the ceiling. More than that on mobile produces contention rather than throughput, and it makes progress reporting meaningless.
Parts retry with exponential backoff:
const uploadPart = async (
part: { part_number: number; url: string },
retries = 0
): Promise<void> => {
// ... slice the file, PUT the chunk ...
try {
const response = await fetch(part.url, { method: 'PUT', body: chunk, /* ... */ });
if (!response.ok) throw new Error(/* ... */);
const etag = response.headers.get('etag');
if (!etag) throw new Error(`No ETag returned for part ${part.part_number}`);
uploadedParts.push({
part_number: part.part_number,
etag: etag.replace(/"/g, '')
});
} catch (error) {
if (retries < MAX_RETRY_ATTEMPTS) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, retries) * 1000)
);
return uploadPart(part, retries + 1);
}
throw error;
}
};const uploadPart = async (
part: { part_number: number; url: string },
retries = 0
): Promise<void> => {
// ... slice the file, PUT the chunk ...
try {
const response = await fetch(part.url, { method: 'PUT', body: chunk, /* ... */ });
if (!response.ok) throw new Error(/* ... */);
const etag = response.headers.get('etag');
if (!etag) throw new Error(`No ETag returned for part ${part.part_number}`);
uploadedParts.push({
part_number: part.part_number,
etag: etag.replace(/"/g, '')
});
} catch (error) {
if (retries < MAX_RETRY_ATTEMPTS) {
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, retries) * 1000)
);
return uploadPart(part, retries + 1);
}
throw error;
}
};1s, 2s, 4s. This is the single highest-value thing in the client, because a 1 GB video is 100 parts and on a flaky connection a handful of them will fail. Retrying one 10 MB chunk is invisible to the user; failing the whole file is not.
Two small details in there. etag.replace(/"/g, '') strips the quotes R2 returns, because CompleteMultipartUpload wants them bare. And:
uploadedParts.sort((a, b) => a.part_number - b.part_number);uploadedParts.sort((a, b) => a.part_number - b.part_number);Parts complete out of order, and they are pushed as they finish. The completion call requires them in ascending part number. Forgetting that sort produces an upload that succeeds for every part and then fails at the last step, which is a genuinely miserable thing to debug.
Partial failure, and one rule that surprised me
The batching loop has an error policy I want to call out, because I went back and forth on it:
} catch (error) {
if (batchStart === 0) {
throw error;
}
// ... otherwise mark this batch's files failed and continue ...
}} catch (error) {
if (batchStart === 0) {
throw error;
}
// ... otherwise mark this batch's files failed and continue ...
}If the first batch fails, the whole operation throws. If a later batch fails, it degrades.
The reasoning is about what the user believes has happened. A failure on the first batch means nothing was uploaded โ quota rejected, not signed in, network down. Throwing surfaces a clean "this did not work" and the user retries the whole thing.
A failure on batch seven of fifteen means 120 files are already in R2. Throwing there would present a total failure for an operation that mostly succeeded, and a retry would re-upload everything. Marking those twenty files failed and continuing lets the user see exactly which ones to retry.
It is asymmetric, it looks like an inconsistency in review, and it is the behaviour users actually want.
Knowing when it is really done
A successful PUT means the bytes are in R2. It does not mean the file is usable โ validation happens asynchronously, driven by R2 event notifications, and until that runs the row is still pending.
So the client polls:
async getUploadStatuses(request: UploadStatusRequest): Promise<UploadStatusItem[]> {
const lookup = await this.target.createStatusLookup(
request.params, this.userId, this.prisma
);
const statusesByKey = new Map<string, 'pending' | 'completed'>();
const uniqueKeys = [...new Set(request.keys)];
for (const status of await findInChunks(uniqueKeys, lookup)) {
statusesByKey.set(status.key, status.status);
}
return request.keys.map(key => ({
key,
status: statusesByKey.get(key) ?? 'failed'
}));
}async getUploadStatuses(request: UploadStatusRequest): Promise<UploadStatusItem[]> {
const lookup = await this.target.createStatusLookup(
request.params, this.userId, this.prisma
);
const statusesByKey = new Map<string, 'pending' | 'completed'>();
const uniqueKeys = [...new Set(request.keys)];
for (const status of await findInChunks(uniqueKeys, lookup)) {
statusesByKey.set(status.key, status.status);
}
return request.keys.map(key => ({
key,
status: statusesByKey.get(key) ?? 'failed'
}));
}The default is the important line. A key with no row is reported failed, not pending. That is the validation pipeline having rejected and deleted it โ wrong type, too large, no matching record. From the client's side an absent row is indistinguishable from a rejection, and reporting it as still-pending would leave the UI spinning forever on a file that no longer exists.
findInChunks is there because WHERE key IN (...) is subject to D1's 100-bound-parameter ceiling; the deduplication via Set before the lookup is free and saves a surprising number of parameters when a client re-polls overlapping key sets.
Every file gets five minutes (DEFAULT_UPLOAD_PENDING_TIMEOUT_MS) to leave pending before the client gives up on it, polling every five seconds.
What I would change
The threshold is a guess. 100 MB was chosen by reasoning, not measurement. I have never A/B'd it against 50 MB or 200 MB, and the right value probably differs between a laptop on fibre and a phone on 4G.
Multipart uploads are not resumable across page loads. The upload ID and completed parts live in React state. A user who closes the tab at 80% of a 1 GB video starts over, even though R2 is still holding those parts. Persisting {uploadId, key, completedParts} to IndexedDB would make it resumable, and the parts are already there waiting.
Abandoned multipart sessions leak. A client that starts one and never completes it leaves R2 holding parts that cost storage and are invisible to my database. A cron job reconciles these, but the reconciliation is a cleanup job, not a design โ the flow itself has no notion of abandonment.
Content-Length is set manually on the XHR. Browsers set it themselves and ignore the attempt. It is harmless, it is signed into the URL and therefore sent regardless, and it reads as though it is doing something. Dead code that looks load-bearing is worse than dead code that looks dead.
Next in this series: the download job that never reports back, and how R2 event notifications became my fan-in barrier.
EventAssets is a serverless event media platform built on Cloudflare Workers, D1, R2 and Queues. This post is part of a series on the architecture behind it.