Upload to R2 through the Worker; presign a PUT for big files

env.BUCKET.put(key, request.body, { httpMetadata }) streams a body to R2 under UUID keys. Above the zone's body limit, hand the browser a presigned PUT.

KV/R2/D1

· Chapter

50

·

3

min read

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.com endpoint, 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. Pass onlyIf: { 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