The answer. For anything a browser can post in one request, let the Worker be the uploader: read request.body (or formData() for multipart forms), choose the key yourself, and call put() with the content type in httpMetadata. Streaming the body means the Worker never holds the file in its 128 MB of memory. Choose keys like uploads/<uuid>.<ext>, never the user's filename, and record the mapping in D1. Above the request-body limit of your zone plan (100 MB on Free and Pro) or when you would rather not proxy bytes at all, generate a presigned PUT URL with aws4fetch and R2 API credentials; the browser uploads directly to the S3 endpoint and the Worker only issues the URL. Very large objects use multipart: parts of at least 5 MiB, up to 10,000 of them, and incomplete uploads are discarded after seven days.
The pattern.
// small: through the Worker
const form = await request.formData();
const file = form.get("file") as File;
const key = `uploads/${crypto.randomUUID()}.${ext(file.type)}`;
await env.BUCKET.put(key, file.stream(), { httpMetadata: { contentType: file.type }, customMetadata: { owner: userId } });
// big: presigned PUT, one hour
import { AwsClient } from "aws4fetch";
const r2 = new AwsClient({ accessKeyId: env.R2_KEY_ID, secretAccessKey: env.R2_SECRET });
const url = new URL(`https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com/${env.BUCKET_NAME}/${key}`);
url.searchParams.set("X-Amz-Expires", "3600");
const signed = await r2.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } });
return Response.json({ uploadUrl: signed.url, key });
Watch out.
- Presigned URLs work only on the
r2.cloudflarestorage.comendpoint, not on a custom domain, and there is no presigned POST for HTML forms. - A browser PUT to R2 needs a CORS rule on the bucket, or the preflight fails before any bytes move.
put()overwrites silently. PassonlyIf: { etagDoesNotMatch: "*" }semantics through headers when a key must be write-once.
Related: r2-serve-objects-with-their-own-headers-etag-range · r2-custom-domain-choose-once · queue-work-returns-202-client-polls-a-status-row