| 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, quota errors, 429 rate limits, list() metadata missing, or platform outages. Prevents 13 documented errors including r2.dev rate limiting, concurrent write limits, API token permissions, and CORS format confusion.
|
| user-invocable | true |
Cloudflare R2 Object Storage
Status: Production Ready โ
Last Updated: 2026-01-20
Dependencies: cloudflare-worker-base (for Worker setup)
Latest Versions: wrangler@4.59.2, @cloudflare/workers-types@4.20260109.0, aws4fetch@1.0.20
Recent Updates (2025):
- September 2025: R2 SQL open beta (serverless query engine for Apache Iceberg), Pipelines GA (real-time stream ingestion), Remote bindings GA (local dev connects to deployed R2)
- May 2025: Dashboard redesign (deeplink support, bucket settings centralization), Super Slurper 5x faster (rebuilt with Workers/Queues/Durable Objects)
- April 2025: R2 Data Catalog open beta (managed Apache Iceberg catalog), Event Notifications open beta (5,000 msg/s per Queue)
- 2025: Bucket limits increased (1 million max), CRC-64/NVME checksums, Server-side encryption with customer keys, Infrequent Access storage class (beta), Oceania region, S3 API enhancements (sha256/sha1 checksums, ListParts, conditional CopyObject)
Quick Start (5 Minutes)
npx wrangler r2 bucket create my-bucket
type Bindings = { MY_BUCKET: R2Bucket };
// Upload
await env.MY_BUCKET.put('file.txt', data, {
httpMetadata: { contentType: 'text/plain' }
});
// Download
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,
},
});
npx wrangler deploy
R2 Workers API
Core Methods
await env.MY_BUCKET.put('file.txt', data, {
httpMetadata: {
contentType: 'text/plain',
cacheControl: 'public, max-age=3600',
},
customMetadata: { userId: '123' },
md5: await crypto.subtle.digest('MD5', data),
});
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.get('file.txt');
if (!object) return c.json({ error: 'Not found' }, 404);
const text = await object.text();
const json = await object.json();
const buffer = await object.arrayBuffer();
const partial = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 1024 * 1024 }
});
const object = await env.MY_BUCKET.head('file.txt');
console.log(object.size, object.etag, object.customMetadata);
await env.MY_BUCKET.delete('file.txt');
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt']);
const listed = await env.MY_BUCKET.list({
prefix: 'images/',
limit: 100,
cursor: cursor,
delimiter: '/',
include: ['httpMetadata', 'customMetadata'],
});
for (const object of listed.objects) {
console.log(`${object.key}: ${object.size} bytes`);
console.log(object.httpMetadata?.contentType);
console.log(object.customMetadata);
}
Multipart Uploads
For files >100MB or resumable uploads. Use when: large files, browser uploads, parallelization needed.
const multipart = await env.MY_BUCKET.createMultipartUpload('large-file.zip', {
httpMetadata: { contentType: 'application/zip' }
});
const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
const part1 = await multipart.uploadPart(1, chunk1);
const part2 = await multipart.uploadPart(2, chunk2);
const object = await multipart.complete([
{ partNumber: 1, etag: part1.etag },
{ partNumber: 2, etag: part2.etag },
]);
await multipart.abort();
Limits: Parts 5MB-100MB, max 10,000 parts per upload. Don't use for files <5MB (overhead).
Presigned URLs
Allow clients to upload/download directly to/from R2 (bypasses Worker). Use aws4fetch library.
import { AwsClient } from 'aws4fetch';
const r2Client = new AwsClient({
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});
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 } }
);
await fetch(signed.url, { method: 'PUT', body: file });
CRITICAL Security:
- โ NEVER expose R2 access keys in client-side code
- โ
ALWAYS generate presigned URLs server-side
- โ
ALWAYS set expiry times (1-24 hours typical)
- โ
ALWAYS add authentication before generating URLs
- โ
CONSIDER scoping to user folders:
users/${userId}/${filename}
Presigned URL Domain Requirements
CRITICAL: Presigned URLs ONLY work with the S3 API domain, not custom domains.
const url = new URL(`https://cdn.example.com/${filename}`);
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
const url = new URL(
`https://${accountId}.r2.cloudflarestorage.com/${filename}`
);
const signed = await r2Client.sign(
new Request(url, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
async function generateUploadUrl(filename: string) {
const uploadUrl = new URL(
`https://${accountId}.r2.cloudflarestorage.com/${filename}`
);
const signed = await r2Client.sign(
new Request(uploadUrl, { method: 'PUT' }),
{ aws: { signQuery: true } }
);
return {
uploadUrl: signed.url,
publicUrl: `https://cdn.example.com/${filename}`
};
}
Source: Community Knowledge
API Token Requirements for Wrangler
โ ๏ธ Wrangler CLI requires "Admin Read & Write" permissions, not "Object Read & Write".
When creating API tokens for wrangler operations:
- โ
Use: R2 โ Admin Read & Write
- โ Don't use: R2 โ Object Read & Write (causes 403 Forbidden errors)
Why: "Object Read & Write" is for S3 API direct access only. Wrangler needs admin-level permissions for bucket operations.
export CLOUDFLARE_API_TOKEN="token_with_object_readwrite"
wrangler r2 object put my-bucket/file.txt --file=./file.txt --remote
wrangler r2 object put my-bucket/file.txt --file=./file.txt --remote
Source: GitHub Issue #9235
CORS Configuration
Configure CORS in bucket settings (Dashboard โ R2 โ Bucket โ Settings โ CORS Policy) before browser access.
Dashboard Format vs CLI Format
โ ๏ธ The wrangler CLI and Dashboard UI use DIFFERENT CORS formats. This commonly causes confusion.
Dashboard Format (works in UI only):
[{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
CLI Format (required for wrangler r2 bucket cors):
{
"rules": [{
"allowed": {
"origins": ["https://www.example.com"],
"methods": ["GET", "PUT"],
"headers": ["Content-Type", "Authorization"]
},
"exposeHeaders": ["ETag", "Content-Length"],
"maxAgeSeconds": 8640
}]
}
wrangler r2 bucket cors set my-bucket --file cors-config.json
Source: GitHub Issue #10076
Custom Domain CORS
When using custom domains with R2, CORS is handled in two layers:
- R2 Bucket CORS: Applies to all access methods (presigned URLs, direct S3 access)
- Transform Rules CORS: Additional CORS headers via Cloudflare Cache settings on custom domain
{
"rules": [{
"allowed": {
"origins": ["https://app.example.com"],
"methods": ["GET", "PUT"],
"headers": ["Content-Type"]
},
"maxAgeSeconds": 3600
}]
}
Source: Community Knowledge
For presigned URLs: CORS handled by R2 directly (configure on bucket, not Worker).
HTTP Metadata & Custom Metadata
await env.MY_BUCKET.put('file.pdf', data, {
httpMetadata: {
contentType: 'application/pdf',
cacheControl: 'public, max-age=31536000, immutable',
contentDisposition: 'attachment; filename="report.pdf"',
contentEncoding: 'gzip',
},
customMetadata: {
userId: '12345',
version: '1.0',
}
});
const object = await env.MY_BUCKET.head('file.pdf');
console.log(object.httpMetadata, object.customMetadata);
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
R2 experienced two major outages in Q1 2025 (February 6: 59 minutes, March 21: 1h 7min) due to operational issues. Implement robust retry logic with exponential backoff for platform errors.
async function r2WithRetry<T>(
operation: () => Promise<T>,
maxRetries = 5
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error: any) {
const message = error.message;
const is5xxError =
message.includes('500') ||
message.includes('502') ||
message.includes('503') ||
message.includes('504');
const isRetryable =
is5xxError ||
message.includes('network') ||
message.includes('timeout') ||
message.includes('temporarily unavailable');
if (!isRetryable || attempt === maxRetries - 1) {
throw error;
}
const delay = is5xxError
? Math.min(1000 * Math.pow(2, attempt), 16000)
: Math.min(1000 * Math.pow(2, attempt), 5000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Max retries exceeded');
}
const object = await r2WithRetry(() =>
env.MY_BUCKET.get('important-file.txt')
);
Platform Reliability: While R2 is generally reliable, the 2025 Q1 outages demonstrate the importance of retry logic for production applications. All 5xx errors should be retried with exponential backoff.
Sources:
Performance Optimization
await env.MY_BUCKET.delete(['file1.txt', 'file2.txt', 'file3.txt']);
const partial = await env.MY_BUCKET.get('video.mp4', {
range: { offset: 0, length: 10 * 1024 * 1024 }
});
await env.MY_BUCKET.put('static/app.abc123.js', jsData, {
httpMetadata: { cacheControl: 'public, max-age=31536000, immutable' }
});
const md5Hash = await crypto.subtle.digest('MD5', fileData);
await env.MY_BUCKET.put('important.dat', fileData, { md5: md5Hash });
Concurrent Write Rate Limits
โ ๏ธ High-frequency concurrent writes to the same object key will trigger HTTP 429 rate limiting.
async function logToSharedFile(env: Env, logEntry: string) {
const existing = await env.LOGS.get('global-log.txt');
const content = (await existing?.text()) || '';
await env.LOGS.put('global-log.txt', content + logEntry);
}
async function logWithSharding(env: Env, logEntry: string) {
const timestamp = Date.now();
const shard = Math.floor(timestamp / 60000);
await env.LOGS.put(`logs/${shard}.txt`, logEntry, {
customMetadata: { timestamp: timestamp.toString() }
});
}
Source: R2 Limits Documentation
R2.dev Domain Rate Limiting
๐จ CRITICAL: The {bucket}.{account}.r2.cloudflarestorage.com (r2.dev) domain is NOT for production use.
r2.dev limitations:
- โ Variable rate limiting (starts at ~hundreds of requests/second)
- โ Bandwidth throttling
- โ No SLA or performance guarantees
- โ You'll receive 429 Too Many Requests under load
For production: ALWAYS use custom domains
const publicUrl = `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${key}`;
const productionUrl = `https://cdn.example.com/${key}`;
r2.dev is ONLY for testing/development. Custom domains are required for production.
Source: R2 Limits Documentation
Best Practices Summary
Always Do:
- Set
contentType for all uploads
- Use batch delete for multiple objects (up to 1000)
- Set cache headers for static assets
- Use presigned URLs for large client uploads (S3 domain only)
- Use multipart for files >100MB
- Set CORS before browser uploads (use CLI format for wrangler)
- Set expiry times on presigned URLs (1-24 hours)
- Use
head() when you only need metadata
- Use conditional operations to prevent overwrites
- Use custom domains for production (never r2.dev)
- Shard writes across keys to avoid rate limits
- Use
include parameter with list() to get metadata
- Implement retry logic with exponential backoff for 5xx errors
Never Do:
- Never expose R2 access keys in client-side code
- Never skip
contentType (files download as binary)
- Never delete in loops (use batch delete)
- Never skip CORS for browser uploads
- Never use multipart for small files (<5MB)
- Never delete >1000 keys in single call
- Never skip presigned URL expiry (security risk)
- Never use r2.dev domain for production (rate limited)
- Never use presigned URLs with custom domains (use S3 domain)
- Never write to same key at high frequency (causes 429)
- Never use "Object Read & Write" tokens for wrangler (use "Admin Read & Write")
Multi-Tenant Architecture
With the bucket limit increased to 1 million buckets per account, per-tenant buckets are now viable for large-scale applications.
const bucketName = `tenant-${tenantId}`;
const bucket = env[bucketName];
await env.MY_BUCKET.put(`tenants/${tenantId}/file.txt`, data);
Source: R2 Limits Documentation
Known Issues Prevented
This skill prevents 13 documented issues:
| Issue # | Issue | Error | Prevention |
|---|
| #1 | CORS errors in browser | Browser can't upload/download | Configure CORS in bucket settings, use correct CLI format |
| #2 | Files download as binary | Missing content-type | Always set httpMetadata.contentType on upload |
| #3 | Presigned URL expiry | URLs never expire | Always set X-Amz-Expires (1-24 hours) |
| #4 | Multipart upload limits | Parts exceed limits | Keep parts 5MB-100MB, max 10,000 parts |
| #5 | Bulk delete limits | >1000 keys fails | Chunk deletes into batches of 1000 |
| #6 | Custom metadata overflow | Exceeds 2KB limit | Keep custom metadata under 2KB |
| #7 | list() metadata missing | httpMetadata undefined | Use include: ['httpMetadata', 'customMetadata'] parameter (Issue #10870) |
| #8 | CORS format confusion | "Must contain 'rules' array" | Use CLI format with rules wrapper for wrangler (Issue #10076) |
| #9 | API token 403 errors | "Failed to fetch - 403" | Use "Admin Read & Write" not "Object Read & Write" for wrangler (Issue #9235) |
| #10 | r2.dev rate limiting | HTTP 429 in production | Use custom domains, never r2.dev for production (R2 Limits) |
| #11 | Concurrent write 429s | Same key written frequently | Shard writes across different keys (R2 Limits) |
| #12 | Presigned URL domain error | Presigned URLs fail | Use S3 domain only, not custom domains (Community) |
| #13 | Platform outages | 5xx errors during outages | Implement retry logic with exponential backoff (Feb 6, Mar 21) |
Development Best Practices
Local R2 Storage Cleanup
โ ๏ธ Local R2 DELETE operations don't cleanup blob files. When using wrangler dev, deleted objects remain in .wrangler/state/v3/r2/{bucket-name}/blobs/, causing local storage to grow indefinitely.
du -sh .wrangler/state/v3/r2/
rm -rf .wrangler/state/v3/r2/
wrangler dev --remote
Source: GitHub Issue #10795
Remote R2 Access Issues
โ ๏ธ Local dev with --remote can have unreliable .get() operations. Some users report get() returning undefined despite put() working correctly.
wrangler dev
wrangler deploy --env preview
async function safeGet(bucket: R2Bucket, key: string) {
for (let i = 0; i < 3; i++) {
const obj = await bucket.get(key);
if (obj && obj.body) return obj;
await new Promise(r => setTimeout(r, 1000));
}
throw new Error('Failed to get object after retries');
}
Source: GitHub Issue #8868 (Community-sourced)
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! ๐
Last verified: 2026-01-20 | Skill version: 2.0.0 | Changes: Added 7 new known issues from community research (list() metadata, CORS format confusion, API token permissions, r2.dev rate limiting, concurrent write limits, presigned URL domain requirements, platform outage retry patterns). Enhanced retry logic for 5xx errors, added development best practices section, documented bucket limit increase to 1M.