R2 object storage: S3-compat, zero egress, and 4 access patterns

R2 is Cloudflare's S3-compatible object storage with no egress fees. R2 vs S3 in real costs, 4 access patterns, S3 migration, and gotchas around consistency, metadata, lifecycle.

· 6 min read · Đọc bản tiếng Việt
Cloudflare R2 object storage: S3-compatible API with no egress fees, 4 serving patterns (Worker binding, public bucket, presigned URL, multipart), S3 migration path and consistency/metadata/lifecycle gotchas

TL;DR

R2 is Cloudflare’s object storage: S3 API v4 compatible, zero egress fee, native Worker integration via bindings, one-click custom domains.

The key claim:

If your workload has any meaningful egress (media, downloads, asset delivery), R2 saves 90%+ versus S3. If you only store a few GB of cold backups, S3 Glacier is cheaper. Decide based on the egress-to-storage ratio, not “Cloudflare is better than AWS”.

This post covers: R2 vs S3 in real cost terms, 4 access patterns (Worker binding proxy, public bucket, presigned URL, multipart), migration from S3, and gotchas around strong consistency, metadata, and lifecycle.


Who this is for

  • Developers paying significant S3 egress and evaluating R2.
  • Teams building media storage (images, video, PDF) that needs custom domains + CDN.
  • Anyone migrating from S3 who wants to keep their boto3 / aws-sdk client.

Read first: Part 3 (Storage layer), Part 5 (KV — decision tree for when to use R2).

After this post you’ll:

  • Decide R2 vs S3 based on a real cost model.
  • Pick the right access pattern (Worker proxy vs public bucket vs presigned).
  • Migrate from S3 via Super Slurper or self-managed dual-write.
  • Avoid eventual-consistency list, CORS, and cache gotchas.

What this post isn’t about

  • Intro to S3: assumed you already use it.
  • Image transforms (resize, crop): that’s Cloudflare Images, Part 16.
  • R2 SQL (beta): not yet covered.
  • R2 Data Catalog: analytics workload, out of scope for this series.

R2 vs S3: when R2 wins

R2 vs S3 comparison across API compatibility, egress fee, storage price, request cost, region model, custom domain, Worker access. R2: S3 v4 compat, $0 egress, $0.015/GB storage, one-click custom domain via Cloudflare DNS, native Worker binding. S3: $0.09/GB internet egress, $0.023/GB Standard storage, needs CloudFront + Route53 for custom domains.

Where R2 clearly wins

  • Media hosting: blog images, short videos, PDF reports — high egress-to-storage ratio.
  • CDN origin: R2 as the origin, Cloudflare’s CDN caches for free.
  • Direct client downloads: apps downloading files from storage, skipping the backend.
  • Avatars, user uploads: egress is typically 10x storage or more.

Where S3 is still better

  • Cold backup storage: S3 Glacier Deep Archive at $0.00099/GB/month.
  • AWS ecosystem integration: Athena queries, Lambda triggers, SNS notifications — R2 has no native equivalents yet.
  • Region-specific compliance: S3 lets you pick a region explicitly; R2 uses location hints.
  • In-VPC, non-public workloads: S3 VPC endpoints save egress.

A real cost model

Assume: 100 GB storage, 10 TB egress/month (a media-heavy blog + downloads).

R2S3
Storage$1.50$2.30
Egress$0$900 (10 TB × $0.09)
Requests~$1-5~$1-5
Total~$5~$905

99% of the cost is egress. That’s why R2 appeared right after AWS raised egress.

The flip case: 10 GB of cold backups, no egress.

R2S3 Glacier
Storage$0.15$0.01
Retrievalfree$0.03/GB

For rarely-retrieved backups, S3 Glacier is 15× cheaper.


Binding

{
  "r2_buckets": [
    {
      "binding": "BUCKET",
      "bucket_name": "my-media",
      "preview_bucket_name": "my-media-preview"
    }
  ]
}

In the Worker:

interface Env {
  BUCKET: R2Bucket;
}

Basic API

// Put an object
await env.BUCKET.put("images/logo.png", imageBytes, {
  httpMetadata: { contentType: "image/png" },
  customMetadata: { uploadedBy: "user:123" }
});

// Get an object
const object = await env.BUCKET.get("images/logo.png");
if (!object) return new Response("Not found", { status: 404 });

// Stream it back
return new Response(object.body, {
  headers: {
    "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
    "ETag": object.httpEtag,
  }
});

// Delete
await env.BUCKET.delete("images/logo.png");

// List with a prefix
const listed = await env.BUCKET.list({ prefix: "images/", limit: 100 });
for (const obj of listed.objects) {
  console.log(obj.key, obj.size, obj.uploaded);
}

// Head (metadata only, no body fetched)
const head = await env.BUCKET.head("images/logo.png");

4 access patterns

R2 access patterns for serving objects: Worker binding proxy, public bucket with custom domain, S3-compatible presigned URL, and multipart upload for large files. A decision tree below picks the pattern based on auth, transform, and size requirements.

① Worker binding (proxy)

Client → Worker → R2, with full control.

async fetch(request, env) {
  const url = new URL(request.url);
  const key = url.pathname.slice(1);

  // Auth check
  const jwt = request.headers.get("Cf-Access-Jwt-Assertion");
  const claims = await verifyJwt(jwt, env);
  if (!claims) return new Response("Unauthorized", { status: 401 });

  // Fetch from R2
  const object = await env.BUCKET.get(key);
  if (!object) return new Response("Not found", { status: 404 });

  // Log the access
  ctx.waitUntil(logAccess(env, claims.email, key));

  // Stream
  return new Response(object.body, {
    headers: {
      "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
      "Cache-Control": "private, max-age=300",
    }
  });
}

Use when:

  • You need per-object auth.
  • You need a transform (resize, watermark, encrypt-at-rest).
  • You need an access log per request.
  • The object is small and routing traffic through the Worker is acceptable.

Gotcha: each request is 1 Worker invocation + 1 R2 subrequest. For high-traffic delivery, the Worker cost dominates the R2 cost. For anything larger than a few MB, look at the other patterns.

② Public bucket + custom domain

Client → cdn.example.com/key, Cloudflare serves directly without the Worker.

Setup:

  1. R2 bucket → Settings → Public access → Connect Domain.
  2. Pick a domain on Cloudflare DNS (cdn.example.com).
  3. Cloudflare creates the SSL cert and routes automatically.
  4. The object is accessible at https://cdn.example.com/key.

Use when:

  • Public assets: blog images, logos, PDF reports.
  • No auth needed.
  • High traffic (CDN cache helps).

Gotcha:

  • The whole bucket is public. You can’t control per-object.
  • CDN cache: invalidate via the Cloudflare dashboard or purge API.
  • CORS: set via bucket config, not per-object.

This blog uses this pattern for images.cloudsecop.net (serving cover SVG/PNG files).

③ Presigned URL (S3 compat)

The Worker signs a URL; the client uploads/downloads directly to R2.

import { AwsClient } from "aws4fetch";

async function getPresignedPutUrl(env: Env, key: string): Promise<string> {
  const aws = new AwsClient({
    accessKeyId: env.R2_ACCESS_KEY_ID,
    secretAccessKey: env.R2_SECRET_ACCESS_KEY,
    service: "s3",
  });

  const endpoint = `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.BUCKET_NAME}/${key}`;

  const signed = await aws.sign(
    new Request(endpoint, { method: "PUT" }),
    { aws: { signQuery: true }, expiresIn: 3600 }  // 1 hour
  );

  return signed.url;
}

Client:

const { url } = await fetch("/api/upload-url", { method: "POST" }).then(r => r.json());
await fetch(url, { method: "PUT", body: file });

Use when:

  • The client uploads directly, bypassing the Worker (saves Worker CPU time).
  • You want URL TTL (auto-expires).
  • You want to keep a boto3 / aws-sdk client in a non-Cloudflare backend.

Gotcha:

  • You need R2 access key + secret (generated in the dashboard).
  • A presigned URL has no auth claim beyond its signature. If it leaks within the TTL, anyone can use it.
  • Maximum TTL is 7 days.

④ Multipart upload

For files > 100MB or when you need resumability.

// Step 1: create upload
const upload = await env.BUCKET.createMultipartUpload("large-video.mp4", {
  httpMetadata: { contentType: "video/mp4" }
});

// Step 2: upload parts (can run in parallel, from multiple requests)
const part1 = await upload.uploadPart(1, chunk1);  // 5MB - 5GB per part
const part2 = await upload.uploadPart(2, chunk2);

// Step 3: complete
await upload.complete([part1, part2]);

// Or abort on failure
// await upload.abort();

Use when:

  • Uploading > 100MB in one Worker request (subrequest wall-time limit).
  • Client upload needs resume (pause + resume).
  • Streaming from a large source (transform + progressive upload).

Gotcha:

  • Abandoned multipart uploads still cost storage. Set a lifecycle policy to auto-delete.
  • Part size: 5MB - 5GB.
  • Maximum 10,000 parts per upload.

Migrating from S3

Super Slurper (dashboard, one-click)

R2 dashboard → Bucket → Data Migration → From S3.

Provide:

  • S3 bucket name + AWS credentials (read-only is fine).
  • Destination R2 bucket.

Cloudflare runs the copy asynchronously and emails you when it’s done. For large (TB-scale) volumes, expect several hours. It’s not a real-time sync.

Self-managed (aws-sdk + env.BUCKET.put)

For continuous sync or mid-migration transforms:

// Worker or Node script
import { S3Client, GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";

async function migrate(env: Env) {
  const s3 = new S3Client({ region: "us-east-1" });
  let continuationToken: string | undefined;

  do {
    const list = await s3.send(new ListObjectsV2Command({
      Bucket: "old-s3-bucket",
      ContinuationToken: continuationToken,
    }));

    for (const obj of list.Contents ?? []) {
      const { Body } = await s3.send(new GetObjectCommand({
        Bucket: "old-s3-bucket",
        Key: obj.Key!,
      }));

      const stream = Body as ReadableStream;
      await env.BUCKET.put(obj.Key!, stream, {
        httpMetadata: { contentType: obj.ContentType ?? "application/octet-stream" }
      });
    }

    continuationToken = list.NextContinuationToken;
  } while (continuationToken);
}

Dual-write strategy

For critical systems, with zero downtime:

  1. Week 1: dual-write — upload code writes to both S3 and R2.
  2. Week 2: migrate historical data (Super Slurper).
  3. Week 3: dual-read — read R2, fall back to S3 on miss.
  4. Week 4: monitor, confirm R2 has 100% coverage.
  5. Week 5: disable S3 writes, R2 only.
  6. Month 2: delete S3 (after a grace period).

Don’t rush step 5. One missing object = a user complaint.


Gotchas

① Eventual consistency on list

await env.BUCKET.put("a.txt", "...");
const listed = await env.BUCKET.list({ prefix: "a" });
// Might not see "a.txt" immediately

list() can lag a few seconds after put(). Getting by exact key is strongly consistent; list is eventual.

Don’t rely on list right after a write to confirm.

② Custom metadata limit

customMetadata is capped at 2 KB total (keys + values). For richer metadata, encode into the key structure or use a D1 pointer.

// Heavy metadata → split
await env.BUCKET.put(`user/${userId}/file/${fileId}`, body);
await env.DB.prepare("INSERT INTO file_meta (key, metadata) VALUES (?, ?)")
  .bind(key, JSON.stringify(rich_meta))
  .run();

③ Content-Type isn’t auto-detected

// WRONG: R2 doesn't sniff, returns "application/octet-stream"
await env.BUCKET.put("photo.jpg", body);

// RIGHT:
await env.BUCKET.put("photo.jpg", body, {
  httpMetadata: { contentType: "image/jpeg" }
});

Browsers won’t render images with the wrong Content-Type.

④ CORS for browser upload

By default an R2 bucket has no CORS config. Fetching from example.com → R2 gets blocked.

Configure CORS via the dashboard or Wrangler:

wrangler r2 bucket cors put my-bucket --file cors.json

cors.json:

[{
  "AllowedOrigins": ["https://my-app.com"],
  "AllowedMethods": ["GET", "PUT"],
  "AllowedHeaders": ["content-type"],
  "MaxAgeSeconds": 3600
}]

⑤ CDN cache doesn’t auto-invalidate

Public bucket + custom domain → Cloudflare caches objects. A put() to the same key doesn’t purge the old cache.

Options:

// Invalidate via the API
await fetch(`https://api.cloudflare.com/client/v4/zones/${zone}/purge_cache`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ files: [`https://cdn.example.com/${key}`] })
});

Or use versioned keys (image-v2.png) to bypass the cache.

⑥ Lifecycle rule for multipart cleanup

wrangler r2 bucket lifecycle put my-bucket --file lifecycle.json
{
  "rules": [{
    "id": "cleanup-incomplete-multipart",
    "enabled": true,
    "abortIncompleteMultipartUpload": {
      "daysAfterInitiation": 7
    }
  }]
}

Without this rule, a failed multipart upload leaves orphaned parts consuming storage.

⑦ “Egress free” isn’t “infinite scale”

R2 has $0 egress but still charges Class B requests ($0.36/M reads). One million page views × 10 images per view = 10M Class B = $3.60, plus Worker invocations. Still cheap, but not literally free.


Production checklist

  • Pick the right access pattern (Worker / public / presigned / multipart) per use case.
  • Content-Type set explicitly on every put().
  • CORS configured if the browser uploads directly.
  • Lifecycle rule to clean up abandoned multipart uploads (7 days is reasonable).
  • Custom metadata < 2 KB; anything richer in D1.
  • Public buckets don’t contain sensitive data (the whole bucket is exposed).
  • Presigned URL TTLs are the minimum necessary (not the 7-day max).
  • CDN cache policy matches your invalidation strategy.
  • list() is not used to confirm recent writes (eventual consistency).

Wrap-up

R2 is S3-compatible object storage with zero egress, native Worker integration. A big cost win for media / CDN-origin workloads; not a “win-everything” — cold backups still belong on S3 Glacier.

Four access patterns cover 99% of use cases: Worker binding (auth), public bucket (static assets), presigned URL (direct client), multipart (large files). S3 migration has Super Slurper (one-click) or the dual-write strategy (critical systems).

Part 8 next: Queues and Durable Objects — async messaging and stateful coordination. The two hardest primitives to use correctly, and the ones that make Workers a genuinely full-stack platform.


References