tech/cloudflare/r2

R2

Cloudflare R2 — S3-compatible object storage with zero egress fees.

production Cloudflare Workers, S3-compatible clients (AWS SDK v3)
improves: tech/cloudflare

Cloudflare R2

R2 is S3-compatible object storage with zero egress fees — you pay only for storage and operations, not for data transfer out. Critical for AI workloads that serve large outputs.

Free tier: 10GB storage, 1M Class A ops/month (writes), 10M Class B ops/month (reads). Paid: $0.015/GB-month storage, $4.50/M Class A, $0.36/M Class B.

wrangler.toml binding

[[r2_buckets]]
binding = "R2"
bucket_name = "my-bucket"

# For separate read-only public bucket
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "my-assets"
wrangler r2 bucket create my-bucket
wrangler r2 bucket list

Core operations from Workers

// Upload (put)
await env.R2.put('documents/report-2026.pdf', pdfBuffer, {
  httpMetadata: { contentType: 'application/pdf' },
  customMetadata: { uploadedBy: userId, projectId },
});

// Download (get)
const obj = await env.R2.get('documents/report-2026.pdf');
if (!obj) return new Response('Not found', { status: 404 });
return new Response(obj.body, {
  headers: { 'Content-Type': obj.httpMetadata?.contentType ?? 'application/octet-stream' },
});

// Stream directly (memory-efficient for large files)
const obj = await env.R2.get(key);
if (!obj) return new Response('Not found', { status: 404 });
return new Response(obj.body, { headers: { 'Content-Type': obj.httpMetadata?.contentType ?? '' } });

// Delete
await env.R2.delete('documents/old-report.pdf');

// List objects
const listed = await env.R2.list({ prefix: 'documents/', limit: 100 });
for (const obj of listed.objects) {
  console.log(obj.key, obj.size, obj.uploaded);
}

// Head (metadata only, no body)
const head = await env.R2.head('documents/report-2026.pdf');
if (head) console.log(head.size, head.customMetadata);

Presigned URLs (client uploads)

Generate a time-limited URL for direct browser → R2 uploads without going through your Worker:

import { AwsClient } from 'aws4fetch';

const r2 = new AwsClient({
  accessKeyId: env.R2_ACCESS_KEY_ID,
  secretAccessKey: env.R2_SECRET_ACCESS_KEY,
  service: 's3',
  region: 'auto',
});

const url = new URL(`https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET}/${key}`);
url.searchParams.set('X-Amz-Expires', '3600'); // 1 hour

const signed = await r2.sign(new Request(url, { method: 'PUT' }), { aws: { signQuery: true } });
return Response.json({ uploadUrl: signed.url });

Public bucket serving

Enable public access in the Cloudflare dashboard or connect a custom domain:

https://pub-<id>.r2.dev/<key>          # default public URL
https://assets.yourdomain.com/<key>   # custom domain (via Pages or R2 domain settings)

Multipart upload (large files)

// For files >100MB — breaks into parts
const upload = await env.R2.createMultipartUpload(key, { httpMetadata: { contentType } });
const part1 = await upload.uploadPart(1, chunk1);
const part2 = await upload.uploadPart(2, chunk2);
await upload.complete([part1, part2]);

Common Gotchas

See Also