Tutorials Cloudflare Feature Focus

R2: Object Storage Without the Egress Tax

CloudflareCloudflare Feature Focus25 minMay 19, 2026Beginner

If you've ever looked at an AWS bill and noticed that moving the bytes out cost more than storing them, you already understand why R2 exists. Cloudflare R2 is an S3-compatible object store with $0 egress — the bytes leave the bucket for free, forever, no matter how many users download them. That single design choice changes which architectures make sense, and it's why simpleappshipper.com can host 60 GB of tutorial videos and serve them globally without dreading the monthly invoice.

This guide is a working tour of R2 as it ships in 2026 — bindings, the five operations you'll actually use, multipart uploads, the public-bucket vs Worker-proxy trade-off, and a candid look at where R2 still loses to S3.

The mental model

R2 is a flat key-value store where the key is a path-like string (tutorials/swift-intro/01-installing-xcode.mov) and the value is up to 5 TiB of bytes plus a small metadata bag. There are no folders — the slashes in the key are decorative; list just supports prefix and delimiter filtering to fake the experience of "browsing a directory."

Each Worker that needs R2 gets a binding in wrangler.toml:

[[r2_buckets]]
binding = "SCREENS"
bucket_name = "simpleappshipper-releases"

That single declaration plumbs env.SCREENS into your Worker — a typed object with put, get, head, list, delete, and createMultipartUpload. No credentials, no signing, no SDK import. The runtime handles auth because the Worker and the bucket live in the same trust boundary.

If you do need S3-style access — for aws s3 cp, Rclone, a CI pipeline, or any tool that already speaks the S3 API — every bucket also exposes an S3-compatible endpoint at https://<account-id>.r2.cloudflarestorage.com. The same bucket, just talked to over the AWS Signature Version 4 protocol.

Pricing in one paragraph

Storage is $0.015/GB-month for the Standard class, and ~25% of that for Infrequent Access (with a 30-day minimum and a per-GB retrieval fee). Class A operations (write-shaped: PUT, POST, LIST, DELETE, multipart parts) are $4.50 per million. Class B operations (read-shaped: GET, HEAD) are $0.36 per million. Egress is zero regardless of where the bytes go — your viewer, an S3 client, an EC2 instance in Frankfurt, anywhere. There's a Free tier that gives you 10 GB storage, 1M Class A ops, and 10M Class B ops per month — enough to ship a real product before paying a cent.

Compared to S3's egress at $0.09/GB outbound, R2 saves you roughly $90 per TB delivered. At simpleappshipper.com's scale (tens of GB delivered per day) the saving is the entire point.

The five operations you'll actually use

Everything in R2 is a variation on five primitives. Here they are, all pulled or distilled from the production code in saas/src/index.js.

1. put — write an object

await env.SCREENS.put(r2Key, arrayBuffer, {
  httpMetadata: { contentType: 'image/png' },
});

That's the whole "upload" path. The value can be a ReadableStream, ArrayBuffer, string, Blob, or null (to write an empty object). httpMetadata is the small typed bag of HTTP-shaped headers (contentType, contentDisposition, cacheControl, contentEncoding, contentLanguage, cacheExpiry) — R2 echoes those headers back on GET automatically. A separate customMetadata object is available for arbitrary string-to-string tags (limited to 2 KB total).

Two non-obvious behaviours:

2. get — read an object (with optional Range)

// Whole object
const obj = await env.SCREENS.get(key);
 
// Partial — what every browser <video> tag does
const obj = await env.SCREENS.get(key, {
  range: { offset, length: end - offset + 1 },
});

get returns either null (object doesn't exist) or an R2ObjectBody whose .body is a ReadableStream. The single most important method on it is writeHttpMetadata(headers), which copies the stored content-type, cache-control, etag, and friends onto a Headers object you're building for your response. The real Worker that gates SAS's video library uses exactly this pattern:

const obj = await env.SCREENS.get(key, r2opts);
if (!obj) return new Response('Not found', { status: 404 });
 
const h = new Headers();
obj.writeHttpMetadata(h);
h.set('etag', obj.httpEtag);
h.set('accept-ranges', 'bytes');
return new Response(obj.body, { status, headers: h });

3. head — get the metadata without the body

const head = await env.SCREENS.head(key);
if (!head) return new Response('Not found', { status: 404 });
const total = head.size; // bytes

head is cheap (Class B op, no body transfer) and is exactly what you want when:

4. list — paginate a prefix

const listed = await env.SCREENS.list({ prefix: 'tutorials/', limit: 1000 });
for (const obj of listed.objects) {
  console.log(obj.key, obj.size, obj.uploaded);
}
if (listed.truncated) {
  const next = await env.SCREENS.list({
    prefix: 'tutorials/',
    limit: 1000,
    cursor: listed.cursor,
  });
}

list returns up to limit (max 1000) objects in lexicographic key order, with a cursor for the next page. Use delimiter: '/' and a prefix to get the "folder" experience back — common prefixes are returned in delimitedPrefixes.

A subtle but important caveat: list is eventually consistent. An object you just put may not appear in list for a few seconds. If you need to know "did this write happen?" right now, use head, not list.

5. delete — remove one or many

// Single
await env.SCREENS.delete(r2Key);
 
// Bulk — up to 1000 keys per call, single Class A op per key
await env.SCREENS.delete(['a.png', 'b.png', 'c.png']);

Delete is idempotent — deleting a non-existent key is not an error. SAS uses the best-effort variant inside cleanup paths because we'd rather over-delete than block the request on a flaky storage call:

try { await env.SCREENS.delete(row.r2_key); } catch (_) { /* best-effort */ }

Multipart uploads — for anything over ~100 MB

A single put works up to 5 TiB in theory, but in practice anything past 100 MB is much better served by multipart upload: the file is split into parts (5 MiB to 5 GiB each), parts are uploaded in parallel, and a final complete call assembles the object atomically. The browser Uploader we ship in the Mac app uses this exact dance against R2's S3 API.

The Worker-binding flavour:

const mp = await env.SCREENS.createMultipartUpload(key, {
  httpMetadata: { contentType: 'video/mp4' },
});
 
// In parallel, for each chunk i (1-indexed):
const parts = await Promise.all(
  chunks.map((bytes, i) => mp.uploadPart(i + 1, bytes)),
);
 
await mp.complete(parts);
// or, if something fails:
await mp.abort();

Three things worth knowing:

  1. Parts must be ≥ 5 MiB, except the last one. Smaller parts fail the complete call.
  2. Aborts cost storage until they're cleaned up. R2 will eventually reap orphaned parts, but a multi-day stuck upload is real storage you're paying for. Always wire abort() into your failure path.
  3. The S3-compatible endpoint speaks the same protocol, so anything that already knows S3 multipart (the AWS SDK, Rclone, the Mac Finder, etc.) just works against R2.

Three ways to actually serve the bytes

You have three sane patterns for letting users read from R2. The right choice depends on what kind of object it is.

Pattern A — Public bucket on a custom domain

For purely public assets (screenshots in a marketing library, fonts, downloadable installers), turn on R2.dev public access or connect a custom domain (releases.simpleappshipper.com in our case) and serve the URL directly. Bytes flow through Cloudflare's CDN, cache headers stick, and your Worker never sees the request.

This is the simplest and cheapest path. The trade-off is that anyone with the URL has the object — forever. If the asset should ever stop being downloadable, this is the wrong pattern.

Pattern B — Worker proxy with paywall logic

For gated content (paid videos, PDFs, anything per-user), the Worker reads from R2 with its binding and decides what to return. This is the SAS pattern for tutorial videos — every GET /api/video/<key> request goes through /saas/src/index.js, hits the D1 entitlement check, and either streams the bytes back or returns a paywall JSON. The Worker proxy adds Class B reads to your bill (one per range request) but gives you complete control over auth, cache-control, watermarking, and analytics.

The cache rule that matters: free content can ride the public CDN cache (public, max-age=86400), but paid content must ride the private cache (private, max-age=3600) so it never gets stored at a shared edge.

Pattern C — Presigned URLs (S3 API)

The S3-style presigned URL works on R2 over the *.r2.cloudflarestorage.com endpoint and is ideal for direct browser-to-R2 uploads: your Worker issues a short-lived PUT URL, the browser uploads straight to R2 without proxying bytes through your Worker, and the Worker only learns the upload happened via a follow-up request.

Caveat: presigning has historically been tied to the S3 hostname, not your custom domain. Cloudflare has been moving toward presigned URLs for custom domains, but if your tooling expects a presigned link to live on mycdn.example.com, double-check the current docs before promising that to a customer.

A clean upload pipeline

Stitch it together and you get the pattern SAS uses for community screenshots:

Loading diagram…

The Worker mints a short-lived presigned URL, the client uploads directly to R2, then calls back with the object key. The Worker heads the key to confirm the upload landed at the size it claimed, inserts a D1 row pointing at the R2 key, and the asset is now part of the library. No bytes ever transit through the Worker on the upload path — perfect for free-plan request limits and for keeping latency low.

Caching and the CDN

R2 + Cloudflare CDN is the R2 cost-killer. A public R2 object served through your custom domain is cached at every edge POP the same way any other Cloudflare-fronted asset is. Your cache-control header on the put (or on the Worker response, for Pattern B) decides how long.

Three rules of thumb:

A correctly cached object turns into roughly one R2 Class B read per cache miss, not one per request. At simpleappshipper.com's scale that's the difference between "R2 is a rounding error on the bill" and "R2 is most of the bill."

Where R2 still loses to S3

Honest list, from a team that ships on R2 in production:

FeatureS3R2 (today)
Egress fees$0.09/GB out$0
Lifecycle rules (tier transitions, expiry)MatureBasic — IA class, expiration, abort-multipart
Object Lock / legal holdYesNo native equivalent
Cross-region replicationNativeWorkers + scripts
Encryption with customer-managed keys (SSE-KMS)YesNo (SSE-S3-equivalent only)
Event notificationsSNS / SQS / EventBridgeR2 → Queues (newer, simpler)
Server-side compose / tag-based opsMatureSparser
Inventory reportsDaily/weekly out of the boxRoll your own with list

If your workload depends on object lock for compliance, SSE-KMS for regulatory reasons, or the deeper IAM-policy surface that S3 + KMS gives you, R2 isn't there yet. For everything else — and especially anything where egress dominates the bill — R2 is the right answer.

The pros and cons cheat sheet

Pros

Cons

When to reach for R2

Use R2 when any of the following is true:

Use S3 (or GCS, or Azure Blob) when any of the following is true:

For an indie/SMB stack, R2 is almost always the right default. The egress math is too good to ignore, and the binding API means there's barely any code between a Worker and a stored object.

In the next chapter we'll walk through D1, Cloudflare's SQLite-at-the-edge database — the other half of the storage story, the one that holds the metadata about everything sitting in R2.

Ch 1: Streaming Paid Video on CloudflareCh 3: D1 — SQLite at the Edge
Course PlatformBuild a Course Platform on CloudflareBuild a paid video course platform with Cloudflare Workers, R2, D1, auth, Stripe, and paywalls.Production WebProduction Web Apps SeriesProduction patterns for web apps: caching, rate limiting, webhooks, queues, cron jobs, and idempotency.WebUltimate Web Development SeriesWeb development tutorials for HTML, CSS, JavaScript, Next.js, Workers, databases, and production shipping.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access