📦 E-commerce / Media / Streaming

R2 Storage — Zero Egress Fees

Watch egress costs accumulate in real-time as images load from AWS S3 and GCS — while Cloudflare R2 stays at $0.00. Live demo with real images and real byte tracking.

The Problem

"We store product images and media on AWS S3. The egress bill grows every month and we cannot predict it. At peak traffic, transferring 100TB costs ~$9,000 in egress fees alone (AWS S3 Standard, US-East-1, first 10TB tier $0.09/GB)."

The Outcome

$0

egress fee on every gigabyte served from R2 — permanently, not as a promo. R2 storage: $0.015/GB-month. Free tier: 10 GB-month + 1M Class A + 10M Class B operations.

Live demo below

Same images. Same URLs. One Worker. Zero egress cost.

Run Panel A to see real egress costs accumulating on your origin. Read the Worker code in the middle. Run Panel B to see the same images served from R2 — $0.00, always.

AWithout Cloudflare
Without Cloudflare R2AWS egress rates

Same images · egress billed at AWS S3 / GCS rates

AWS S3 egress
$0.00
GCS egress$0.00
Not started
Deploy the Worker
🚀

Deploy the Worker — 3 steps

Copy the code, configure the route, run one command. Your origin server: zero changes.

Save as worker.ts. Intercepts /images/* — serves from R2, falls back to origin, copies in background.

worker.ts
// worker.js — serve images from R2 instead of your origin
// Route: *yourdomain.com/images/*
// Your origin server: ZERO code changes required
//
// wrangler.toml bindings needed:
//   [[r2_buckets]]
//   binding  = "R2"
//   bucket_name = "your-media-bucket"

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url)

    // Only intercept image requests — everything else passes to origin unchanged
    if (!url.pathname.startsWith('/images/')) {
      return fetch(request)
    }

    // Strip leading slash to get the R2 object key
    // e.g. "/images/products/chair.jpg" → "products/chair.jpg"
    const key = url.pathname.slice('/images/'.length)

    // 1. Check R2 first — zero egress fee, served from nearest Cloudflare PoP
    const r2Object = await env.R2.get(key)
    if (r2Object !== null) {
      const headers = new Headers()
      r2Object.writeHttpMetadata(headers)    // copies Content-Type from stored metadata
      headers.set('Cache-Control', 'public, max-age=86400')
      headers.set('X-Served-From', 'cloudflare-r2')
      headers.set('X-Egress-Cost', '$0.00')
      return new Response(r2Object.body, { headers })
    }

    // 2. Not in R2 yet — fetch from origin once, return immediately,
    //    store in R2 in the background so next request is free
    const originResponse = await fetch(request)
    if (!originResponse.ok) return originResponse

    ctx.waitUntil((async () => {
      const buffer = await originResponse.clone().arrayBuffer()
      await env.R2.put(key, buffer, {
        httpMetadata: {
          contentType:  originResponse.headers.get('content-type') ?? 'image/jpeg',
          cacheControl: 'public, max-age=86400',
        },
        customMetadata: {
          'cached-at':  new Date().toISOString(),
          'origin-url': request.url,
        },
      })
    })())

    // Return origin response to user — next request will hit R2
    const respHeaders = new Headers(originResponse.headers)
    respHeaders.set('X-Served-From', 'origin-first-load')
    return new Response(originResponse.body, {
      status:  originResponse.status,
      headers: respHeaders,
    })
  },
}

✅ What stays the same on your server:

Your origin server code
Your image URLs (/images/...)
Your DNS configuration
Your SSL certificate
BWith Cloudflare Worker
Worker Active$0.00 egress

AI-generated images served from R2

R2 egress cost
$0.00FREE
Not started✓ Served from R2

Storage + Egress Pricing — Full Comparison

ProviderStorage/GB-moEgress/GBCDN required?10TB/mo egress100TB/mo egress
AWS S3$0.023$0.09Yes, +$~$900~$9,000
Google Cloud$0.020$0.12Yes, +$~$1,200~$12,000
Cloudflare R2$0.015$0.00No — built-in$0$0

Productionising this

What changes when you ship this for real

Cache API in front of R2

Wrap R2 reads in caches.default.match() first. Hot images served from PoP cache; R2 is the durable backing store. Saves Class B operations (10M/mo free).

Custom Domain on the bucket

Connect a domain to the R2 bucket via Settings → Custom Domains. Enables direct public reads with $0 egress, no Worker in front needed for purely-public assets.

Migration on first miss

The auto-migrate-on-miss pattern in this demo (origin → R2 via ctx.waitUntil) is production-ready. Keep the bypass header to prevent loops if origin and Worker share a hostname.

Lifecycle rules

Set R2 object lifecycle in the dashboard to expire stale variants (old image sizes, deleted products) after N days. Prevents unbounded storage growth.

Presigned URLs for private assets

For paywalled / user-uploaded content, use S3-compatible presigned URLs (S3 SDK works) with short TTL (5–15min). Egress remains $0 even for presigned reads.

Cost alerting

Free tier: 10 GB-month + 1M Class A + 10M Class B operations. Set R2 usage alerts in the dashboard before you exit free tier so you can decide consciously.