| name | cloudflare-r2 |
| description | Store objects with R2's S3-compatible storage on Cloudflare's edge. Use when: uploading/downloading files, configuring CORS, generating presigned URLs, multipart uploads, managing metadata, or troubleshooting R2_ERROR, CORS failures, presigned URL issues, or quota errors.
|
| license | MIT |
Cloudflare R2 Object Storage
Status: Production Ready ✅
Last Updated: 2025-10-21
Dependencies: cloudflare-worker-base (for Worker setup)
Latest Versions: wrangler@4.43.0, @cloudflare/workers-types@4.20251014.0, aws4fetch@1.0.20
Quick Start (5 Minutes)
1. Create R2 Bucket
npx wrangler r2 bucket create my-bucket
Bucket Naming Rules:
- 3-63 characters
- Lowercase letters, numbers, hyphens only
- Must start/end with letter or number
- Globally unique within your account
2. Configure R2 Binding
Add to your wrangler.jsonc:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-11",
"r2_buckets": [
{
"binding": "MY_BUCKET",
"bucket_name": "my-bucket",
"preview_bucket_name": "my-bucket-preview"
}
]
}
CRITICAL:
binding is how you access the bucket in code (env.MY_BUCKET)
bucket_name is the actual R2 bucket name
preview_bucket_name is optional but recommended for separate dev/prod data
3. Basic Upload/Download
import { Hono } from 'hono';
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
app.put('/upload/:filename', async (c) => {
const filename = c.req.param('filename');
const body = await c.req.arrayBuffer();
try {
const object = await c.env.MY_BUCKET.put(filename, body, {
httpMetadata: {
contentType: c.req.header('content-type') || 'application/octet-stream',
},
});
return c.json({
success: true,
key: object.key,
size: object.size,
etag: object.etag,
});
} catch (error: any) {
console.error('R2 Upload Error:', error.message);
return c.json({ error: 'Upload failed' }, 500);
}
});
app.get('/download/:filename', async (c) => {
const filename = c.req.param('filename');
try {
const object = await c.env.MY_BUCKET.get(filename);
if (!object) {
return c.json({ error: 'File not found' }, 404);
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
'Cache-Control': object.httpMetadata?.cacheControl || 'public, max-age=3600',
},
});
} catch (error: any) {
console.error('R2 Download Error:', error.message);
return c.json({ error: 'Download failed' }, 500);
}
});
export default app;
4. Deploy and Test
npx wrangler deploy
curl -X PUT https://my-worker.workers.dev/upload/test.txt \
-H "Content-Type: text/plain" \
-d "Hello, R2!"
curl https://my-worker.workers.dev/download/test.txt
R2 Workers API
Type Definitions
interface Env {
MY_BUCKET: R2Bucket;
}
type Bindings = {
MY_BUCKET: R2Bucket;
};
const app = new Hono<{ Bindings: Bindings }>();
put() - Upload Objects
Signature:
put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, options?: R2PutOptions): Promise<R2Object | null>
Basic Usage:
await env.MY_BUCKET.put('path/to/file.txt', request.body);
await env.MY_BUCKET.put('config.json', JSON.stringify({ foo: 'bar' }));
await env.MY_BUCKET.put('image.png', await file.arrayBuffer());
With Metadata:
const object = await env.MY_BUCKET.put('document.pdf', fileData, {
httpMetadata: {
contentType: 'application/pdf',
contentLanguage: 'en-US',
contentDisposition: 'attachment; filename="report.pdf"',
contentEncoding: 'gzip',
cacheControl: 'public, max-age=86400',
},
customMetadata: {
userId: '12345',
uploadDate: new Date().toISOString(),
version: '1.0',
},
});
Conditional Uploads (Prevent Overwrites):
const object = await env.MY_BUCKET.put('file.txt', data, {
onlyIf: {
uploadedBefore: new Date('2020-01-01'),
},
});
if (!object) {
return c.json({ error: 'File already exists' }, 409);
}
const object = await env.MY_BUCKET.put('file.txt', data, {
onlyIf: {
etagMatches: existingEtag,
},
});
With Checksums:
const md5Hash = await crypto.subtle.digest('MD5', fileData);
await env.MY_BUCKET.put('file.txt', fileData, {
md5: md5Hash,
});
get() - Download Objects
Signature:
get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>
Basic Usage:
const object = await env.MY_BUCKET.get('file.txt');
if (!object) {
return c.json({ error: 'Not found' }, 404);
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'ETag': object.httpEtag,
},
});
Read as Different Formats:
const object = await env.MY_BUCKET.get('data.json');
if (object) {
const text = await object.text();
const json = await object.json();
const buffer = await object.arrayBuffer();
const blob = await object.blob();
}
Range Requests (Partial Downloads):
const object = await env.MY_BUCKET.get('large-file.mp4', {
range: { offset: 0, length: 1024 * 1024 },
});
const object = await env.MY_BUCKET.get('file.bin', {
range: { offset: 100, length: 100 },
});
const object = await env.MY_BUCKET.get('file.bin', {
range: { offset: 1000 },
});
Conditional Downloads:
const object = await env.MY_BUCKET.get('file.txt', {
onlyIf: {
etagMatches: cachedEtag,
},
});
if (!object) {
return c.json({ error: 'File changed' }, 412);
}
head() - Get Metadata Only
Signature:
head(key: string): Promise<R2Object | null>
Usage:
const object = await env.MY_BUCKET.head('file.txt');
if (object) {
console.log({
key: object.key,
size: object.size,
etag: object.etag,
uploaded: object.uploaded,
contentType: object.httpMetadata?.contentType,
customMetadata: object.customMetadata,
});
}
Use Cases:
- Check if file exists
- Get file size before downloading
- Check last modified date
- Validate etag for caching
delete() - Delete Objects
Signature:
delete(key: string | string[]): Promise<void>
Single Delete:
await env.MY_BUCKET.delete('file.txt');
Bulk Delete (Up to 1000 keys):
const keysToDelete = [
'old-file-1.txt',
'old-file-2.txt',
'temp/cache-data.json',
];
await env.MY_BUCKET.delete(keysToDelete);
Delete with Confirmation:
app.delete('/files/:filename', async (c) => {
const filename = c.req.param('filename');
const exists = await c.env.MY_BUCKET.head(filename);
if (!exists) {
return c.json({ error: 'File not found' }, 404);
}
await c.env.MY_BUCKET.delete(filename);
return c.json({ success: true, deleted: filename });
});
list() - List Objects
Signature:
list(options?: R2ListOptions): Promise<R2Objects>
Basic Listing:
const listed = await env.MY_BUCKET.list();
console.log({
objects: listed.objects,
truncated: listed.truncated,
cursor: listed.cursor,
});
for (const object of listed.objects) {
console.log(`${object.key}: ${object.size} bytes`);
}
Pagination:
app.get('/api/files', async (c) => {
const cursor = c.req.query('cursor');
const listed = await c.env.MY_BUCKET.list({
limit: 100,
cursor: cursor || undefined,
});
return c.json({
files: listed.objects.map(obj => ({
name: obj.key,
size: obj.size,
uploaded: obj.uploaded,
etag: obj.etag,
})),
hasMore: listed.truncated,
nextCursor: listed.cursor,
});
});
Prefix Filtering (List by Directory):
const images = await env.MY_BUCKET.list({
prefix: 'images/',
});
const userFiles = await env.MY_BUCKET.list({
prefix: `users/${userId}/`,
});
Delimiter (Folder-like Listing):
const listed = await env.MY_BUCKET.list({
prefix: 'uploads/',
delimiter: '/',
});
console.log('Files:', listed.objects);
console.log('Folders:', listed.delimitedPrefixes);
Multipart Uploads
For files larger than 100MB or for resumable uploads, use multipart upload API.
When to Use Multipart Upload
✅ Use multipart when:
- File size > 100MB
- Need resumable uploads
- Want to parallelize upload
- Uploading from browser/client
❌ Don't use multipart when:
- File size < 5MB (overhead not worth it)
- Uploading within Worker (use direct put())
Basic Multipart Upload Flow
app.post('/api/upload/start', async (c) => {
const { filename } = await c.req.json();
const multipart = await c.env.MY_BUCKET.createMultipartUpload(filename, {
httpMetadata: {
contentType: 'application/octet-stream',
},
});
return c.json({
key: multipart.key,
uploadId: multipart.uploadId,
});
});
app.put('/api/upload/part', async (c) => {
const { key, uploadId, partNumber } = await c.req.json();
const body = await c.req.arrayBuffer();
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const uploadedPart = await multipart.uploadPart(partNumber, body);
return c.json({
partNumber: uploadedPart.partNumber,
etag: uploadedPart.etag,
});
});
app.post('/api/upload/complete', async (c) => {
const { key, uploadId, parts } = await c.req.json();
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const object = await multipart.complete(parts);
return c.json({
success: true,
key: object.key,
size: object.size,
etag: object.etag,
});
});
app.post('/api/upload/abort', async (c) => {
const { key, uploadId } = await c.req.json();
const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
await multipart.abort();
return c.json({ success: true });
});
Complete Multipart Upload Example
interface Env {
MY_BUCKET: R2Bucket;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const key = url.pathname.slice(1);
const action = url.searchParams.get('action');
switch (request.method) {
case 'POST': {
switch (action) {
case 'mpu-create': {
const multipart = await env.MY_BUCKET.createMultipartUpload(key);
return Response.json({
key: multipart.key,
uploadId: multipart.uploadId,
});
}
case 'mpu-complete': {
const uploadId = url.searchParams.get('uploadId')!;
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const parts = await request.json();
const object = await multipart.complete(parts);
return Response.json({
key: object.key,
etag: object.etag,
size: object.size,
});
}
default:
return new Response(`Unknown action: ${action}`, { status: 400 });
}
}
case 'PUT': {
switch (action) {
case 'mpu-uploadpart': {
const uploadId = url.searchParams.get('uploadId')!;
const partNumber = parseInt(url.searchParams.get('partNumber')!);
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const uploadedPart = await multipart.uploadPart(partNumber, request.body!);
return Response.json({
partNumber: uploadedPart.partNumber,
etag: uploadedPart.etag,
});
}
default:
return new Response(`Unknown action: ${action}`, { status: 400 });
}
}
case 'DELETE': {
switch (action) {
case 'mpu-abort': {
const uploadId = url.searchParams.get('uploadId')!;
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
await multipart.abort();
return new Response(null, { status: 204 });
}
default:
return new Response(`Unknown action: ${action}`, { status: 400 });
}
}
default:
return new Response('Method Not Allowed', { status: 405 });
}
},
} satisfies ExportedHandler<Env>;
Presigned URLs
Presigned URLs allow clients to upload/download objects directly to/from R2 without going through your Worker.
When to Use Presigned URLs
✅ Use presigned URLs when:
- Client uploads directly to R2 (saves Worker bandwidth)
- Temporary access to private objects
- Sharing download links with expiry
- Avoiding Worker request size limits
❌ Don't use presigned URLs when:
- You need to process/validate uploads
- Files are already public
- Using custom domains (presigned URLs only work with R2 endpoint)
Generate Presigned URLs with aws4fetch
npm install aws4fetch
import { AwsClient } from 'aws4fetch';
interface Env {
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
ACCOUNT_ID: string;
MY_BUCKET: R2Bucket;
}
const app = new Hono<{ Bindings: Env }>();
app.post('/api/presigned-upload', async (c) => {
const { filename } = await c.req.json();
const r2Client = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
});
const bucketName = 'my-bucket';
const accountId = c.env.ACCOUNT_ID;
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', '3600');
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
return c.json({
uploadUrl: signed.url,
expiresIn: 3600,
});
});
app.post('/api/presigned-download', async (c) => {
const { filename } = await c.req.json();
const r2Client = new AwsClient({
accessKeyId: c.env.R2_ACCESS_KEY_ID,
secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
});
const bucketName = 'my-bucket';
const accountId = c.env.ACCOUNT_ID;
const url = new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
);
url.searchParams.set('X-Amz-Expires', '3600');
const signed = await r2Client.sign(
new Request(url, { method: 'GET' }),
{ aws: { signQuery: true } }
);
return c.json({
downloadUrl: signed.url,
expiresIn: 3600,
});
});
Client-Side Upload with Presigned URL
const response = await fetch('https://my-worker.workers.dev/api/presigned-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: 'photo.jpg' }),
});
const { uploadUrl } = await response.json();
const file = document.querySelector('input[type="file"]').files[0];
await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
});
Presigned URL Security
CRITICAL:
- ❌ NEVER expose R2 access keys in client-side code
- ✅ ALWAYS generate presigned URLs server-side
- ✅ ALWAYS set appropriate expiry times (1-24 hours typical)
- ✅ CONSIDER adding authentication before generating URLs
- ✅ CONSIDER rate limiting presigned URL generation
app.post('/api/presigned-upload', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader) {
return c.json({ error: 'Unauthorized' }, 401);
}
const userId = await verifyToken(authHeader);
if (!userId) {
return c.json({ error: 'Invalid token' }, 401);
}
const { filename } = await c.req.json();
const key = `users/${userId}/${filename}`;
});
CORS Configuration
Configure CORS to allow browser requests to your R2 bucket.
Public Bucket CORS
{
"CORSRules": [
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
}
Allow All Origins (Public Assets)
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range"],
"MaxAgeSeconds": 3600
}
]
}
Upload with CORS
{
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedHeaders": [
"Content-Type",
"Content-MD5",
"x-amz-meta-*"
],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
}
Apply CORS via Dashboard
- Go to Cloudflare Dashboard → R2
- Select your bucket
- Go to Settings tab
- Under CORS Policy → Add CORS policy
- Paste JSON configuration
- Save
CORS for Presigned URLs
When using presigned URLs, CORS is handled by R2 directly. Configure CORS on the bucket, not in your Worker.
HTTP Metadata
Content-Type
await env.MY_BUCKET.put('image.jpg', imageData, {
httpMetadata: {
contentType: 'image/jpeg',
},
});
Cache-Control
await env.MY_BUCKET.put('static/logo.png', logoData, {
httpMetadata: {
contentType: 'image/png',
cacheControl: 'public, max-age=31536000, immutable',
},
});
await env.MY_BUCKET.put('api/data.json', jsonData, {
httpMetadata: {
contentType: 'application/json',
cacheControl: 'public, max-age=60, must-revalidate',
},
});
Content-Disposition
await env.MY_BUCKET.put('report.pdf', pdfData, {
httpMetadata: {
contentType: 'application/pdf',
contentDisposition: 'attachment; filename="monthly-report.pdf"',
},
});
await env.MY_BUCKET.put('image.jpg', imageData, {
httpMetadata: {
contentType: 'image/jpeg',
contentDisposition: 'inline',
},
});
Content-Encoding
await env.MY_BUCKET.put('data.json.gz', gzippedData, {
httpMetadata: {
contentType: 'application/json',
contentEncoding: 'gzip',
},
});
Custom Metadata
Store arbitrary key-value metadata with objects.
Setting Custom Metadata
await env.MY_BUCKET.put('document.pdf', pdfData, {
customMetadata: {
userId: '12345',
department: 'engineering',
uploadDate: new Date().toISOString(),
version: '1.0',
approved: 'true',
},
});
Reading Custom Metadata
const object = await env.MY_BUCKET.head('document.pdf');
if (object) {
console.log(object.customMetadata);
}
Limitations
- Max 2KB total size for all custom metadata
- Keys and values must be strings
- Keys are case-insensitive
- No limit on number of keys (within 2KB total)
Error Handling
Common R2 Errors
try {
await env.MY_BUCKET.put(key, data);
} catch (error: any) {
const message = error.message;
if (message.includes('R2_ERROR')) {
} else if (message.includes('exceeded')) {
} else if (message.includes('precondition')) {
} else if (message.includes('multipart')) {
}
console.error('R2 Error:', message);
return c.json({ error: 'Storage operation failed' }, 500);
}
Retry Logic
async function r2WithRetry<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error: any) {
const message = error.message;
const isRetryable =
message.includes('network') ||
message.includes('timeout') ||
message.includes('temporarily unavailable');
if (!isRetryable || attempt === maxRetries - 1) {
throw error;
}
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Retry logic failed');
}
const object = await r2WithRetry(() =>
env.MY_BUCKET.get('important-file.txt')
);
Performance Optimization
Batch Operations
for (const file of filesToDelete) {
await env.MY_BUCKET.delete(file);
}
await env.MY_BUCKET.delete(filesToDelete);
Range Requests for Large Files
const object = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 10 * 1024 * 1024 },
});
return new Response(object.body, {
status: 206,
headers: {
'Content-Type': 'video/mp4',
'Content-Range': `bytes 0-${10 * 1024 * 1024 - 1}/${object.size}`,
},
});
Cache Headers
await env.MY_BUCKET.put('static/app.abc123.js', jsData, {
httpMetadata: {
contentType: 'application/javascript',
cacheControl: 'public, max-age=31536000, immutable',
},
});
await env.MY_BUCKET.put('api/latest.json', jsonData, {
httpMetadata: {
contentType: 'application/json',
cacheControl: 'public, max-age=60, stale-while-revalidate=300',
},
});
Checksums for Data Integrity
const md5Hash = await crypto.subtle.digest('MD5', fileData);
await env.MY_BUCKET.put('important.dat', fileData, {
md5: md5Hash,
});
Best Practices Summary
✅ Always Do:
- Set appropriate
contentType for all uploads
- Use batch delete for multiple objects (up to 1000)
- Set cache headers (
cacheControl) for static assets
- Use presigned URLs for large client uploads
- Use multipart upload for files > 100MB
- Set CORS policy before allowing browser uploads
- Set expiry times on presigned URLs (1-24 hours)
- Handle errors gracefully with try/catch
- Use
head() instead of get() when you only need metadata
- Use conditional operations to prevent overwrites
❌ Never Do:
- Never expose R2 access keys in client-side code
- Never skip
contentType (files will download as binary)
- Never delete in loops (use batch delete)
- Never upload without error handling
- Never skip CORS for browser uploads
- Never use multipart for small files (< 5MB)
- Never delete >1000 keys in single call (will fail)
- Never assume uploads succeed (always check response)
- Never skip presigned URL expiry (security risk)
- Never hardcode bucket names (use bindings)
Known Issues Prevented
| Issue | Description | How to Avoid |
|---|
| CORS errors in browser | Browser can't upload/download due to missing CORS policy | Configure CORS in bucket settings before browser access |
| Files download as binary | Missing content-type causes browsers to download files instead of display | Always set httpMetadata.contentType on upload |
| Presigned URL expiry | URLs never expire, posing security risk | Always set X-Amz-Expires (1-24 hours typical) |
| Multipart upload limits | Parts exceed 100MB or >10,000 parts | Keep parts 5MB-100MB, max 10,000 parts per upload |
| Bulk delete limits | Trying to delete >1000 keys fails | Chunk deletes into batches of 1000 |
| Custom metadata overflow | Metadata exceeds 2KB limit | Keep custom metadata under 2KB total |
Wrangler Commands Reference
wrangler r2 bucket create <BUCKET_NAME>
wrangler r2 bucket list
wrangler r2 bucket delete <BUCKET_NAME>
wrangler r2 object put <BUCKET_NAME>/<KEY> --file=<FILE_PATH>
wrangler r2 object get <BUCKET_NAME>/<KEY> --file=<OUTPUT_PATH>
wrangler r2 object delete <BUCKET_NAME>/<KEY>
wrangler r2 object list <BUCKET_NAME>
wrangler r2 object list <BUCKET_NAME> --prefix="folder/"
Official Documentation
Ready to store with R2! 🚀