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:
putis atomic — readers either see the previous version or the new one, never a half-written object.- No directory creation step.
PUT tutorials/swift-intro/01.movjust works; the prefix exists because the key exists.
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; // byteshead is cheap (Class B op, no body transfer) and is exactly what you want when:
- You need the object's size to compute a
Content-Rangefor a byte-range request. - You're checking existence before issuing a presigned URL.
- You want the etag to decide whether a downstream cache is still warm.
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:
- Parts must be ≥ 5 MiB, except the last one. Smaller parts fail the
completecall. - 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. - 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:
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:
- Free, public, immutable:
public, max-age=31536000, immutable. Hashed filenames so a new version is a new URL. - Free, public, mutable:
public, max-age=300, s-maxage=86400plus an explicitpurgeon update. - Paid, per-user:
private, max-age=3600. The CDN won't cache; the user's browser will.
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:
| Feature | S3 | R2 (today) |
|---|---|---|
| Egress fees | $0.09/GB out | $0 |
| Lifecycle rules (tier transitions, expiry) | Mature | Basic — IA class, expiration, abort-multipart |
| Object Lock / legal hold | Yes | No native equivalent |
| Cross-region replication | Native | Workers + scripts |
| Encryption with customer-managed keys (SSE-KMS) | Yes | No (SSE-S3-equivalent only) |
| Event notifications | SNS / SQS / EventBridge | R2 → Queues (newer, simpler) |
| Server-side compose / tag-based ops | Mature | Sparser |
| Inventory reports | Daily/weekly out of the box | Roll 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
- $0 egress. Architectures that were too expensive on S3 (large public downloads, AI-generated assets, user-generated video) become trivially affordable.
- Workers binding. No credentials, no SDK, no signing — just
env.BUCKET.get(key). The TypeScript surface is small enough to learn in an afternoon. - S3-compatible. Existing tooling —
awsCLI, Rclone, AWS SDKs, even Cyberduck — works against R2 with two URL changes. - Free tier you can ship on. 10 GB / 1M Class A / 10M Class B per month is enough for real production usage of a small product.
- Cloudflare CDN, same provider. Cache headers travel one hop. Purges are instant. No origin-to-CDN trust setup.
Cons
- No native streaming codec features. R2 is bytes-only; if you want ABR / HLS / DASH packaging, that's Cloudflare Stream's job, not R2's.
- No object lock, no SSE-KMS. Compliance-heavy workloads should evaluate carefully.
listis eventually consistent. Don't write "right after upload, list and confirm" loops; usehead.- Presigned-URL story is still maturing for custom domains. S3 endpoint is the safe path.
- The bucket-per-region story is different. R2 buckets are global with regional hints (
autobjurisdiction-equivalent), not a region-pinned object like S3. Mostly an upside, occasionally a surprise.
When to reach for R2
Use R2 when any of the following is true:
- You serve large files (images, video, downloads, model weights, datasets) to users on the open internet, and your egress bill on S3 would dominate your storage bill.
- You want object storage in the same trust boundary as your Worker code, with no credentials to manage.
- You already use Cloudflare for DNS / CDN / Workers and want one provider, one dashboard, one bill.
Use S3 (or GCS, or Azure Blob) when any of the following is true:
- You need compliance-grade features (object lock, KMS-managed keys, deep audit logs) that R2 doesn't yet match.
- Your bytes never leave AWS — say, S3 → Athena → Redshift — so egress isn't a factor anyway.
- Your team's runbooks and IAM policies are deeply S3-shaped and the migration cost outweighs the egress saving.
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.
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