The disk-full alert was the easy part. A media library outgrows the box it lives on, someone moves it to object storage over a weekend, the alert stops firing, everyone moves on. Two weeks later the complaint is different: images are slow for visitors on the other side of the planet, and there is a line on the bill nobody can explain item by item.
Moving the files is the solved part. Which bucket they land in, and what sits in front of that bucket, decides whether you spend almost nothing or spend more than the server you were trying to shrink.
Cloudflare R2 vs Amazon S3 usually gets reduced to one sentence: R2 does not charge for data transfer out, S3 does, therefore R2. That is wrong in both directions, and understanding why is most of the value here. This post covers the failure mode that shows up after the migration rather than during it, an honest profile of each service, the levers that actually move the number, and a procedure you can run against your own traffic in an afternoon.
Free egress is not free serving
R2 charges nothing to move bytes out, on any storage class. It still charges for the read. Every GetObject and every HeadObject is a Class B operation, and a media library made of thousands of small files racks those up fast. Cloudflare’s own asset hosting example in the R2 pricing docs makes the point: storage falls inside the free tier, writes fall inside the free tier, and the whole bill is read operations.
So the meter is not gigabytes. The meter is requests that reach the bucket, which means the variable deciding your bill on either platform is cache hit ratio.
This is where offloading setups go wrong without anyone noticing. The plugin rewrites image URLs to point at the bucket, images render, pages look fine, and every request for every thumbnail travels all the way to the origin and gets billed. Nothing breaks. Nothing alerts. The number just grows with traffic.
There is a sharper version on the R2 side. A new bucket gives you a public URL on an r2.dev subdomain, which is convenient and tempting. Cloudflare documents it as a testing endpoint with a variable rate limit. Push production traffic through it and requests get throttled with 429 Too Many Requests, throughput can be throttled too, and you get no cache, no WAF, no bot management. Those only exist once the bucket sits behind a custom domain you control. That is the invisible failure: not a broken image, a working setup billed on every request and rate limited under load.
Cloudflare R2: where it wins and where it hurts
Where R2 wins
- Egress really is zero. Not zero within a partner network, not zero up to a ratio of stored data. Zero, on both storage classes, through the S3 API and through Workers.
- A custom domain puts it behind the CDN. One managed CNAME and reads start being absorbed at the edge instead of hitting the bucket. No second product to buy and wire up.
- Deletes are free operations, along with aborting a multipart upload. Cleaning up a messy library costs nothing.
- Unauthorized requests are not billed. A caller without permission gets a 401 and you are not charged, which matters once someone starts hammering your bucket path.
- Migration tooling is free to use. Super Slurper copies a bucket across in bulk, Sippy migrates lazily on first miss. You pay only for the operations they perform against R2.
Where R2 hurts
- S3 compatible is not S3 identical. Cloudflare publishes a table of which operations are implemented and which are not. Real tooling has broken on the gaps: when AWS shipped SDKs that enabled CRC32 checksums by default, R2 rejected the header until the mismatch was resolved.
- There is no cold archive tier. Infrequent Access is as cold as it gets, and it carries a minimum storage duration, a retrieval fee, and doubled operation rates. For an active library that is the wrong tier, not a saving.
- Data lives in one primary location. Location hints are best effort rather than a region guarantee, and are honored only the first time a bucket with that name is created. Read performance away from that location comes from the edge cache, not from replicas.
- Plugin support is thinner. WP Offload Media, the long-standing WordPress option, officially lists Amazon S3, DigitalOcean Spaces, and Google Cloud Storage. R2 users generally land on Media Cloud, Advanced Media Offloader, Next3 Offload, or the S3-Uploads route driven from WP-CLI.
Amazon S3: where it wins and where it hurts
Where S3 wins
- Origin transfer to CloudFront is not billed. This quietly demolishes the usual comparison. AWS waives data transfer from an AWS origin to CloudFront, so a properly built S3 media stack never pays the direct-to-internet rate on cached traffic.
- The CloudFront free allowance is permanent, covering a monthly volume of data transfer out and requests rather than expiring after twelve months. Small sites can sit inside it indefinitely.
- Event driven derivatives are mature. Upload fires a Lambda that generates thumbnails, strips metadata, converts formats, writes the result back. Hard to beat if your media pipeline does real work.
- A full storage ladder with real lifecycle transitions, down to deep archive. If a large chunk of your library is genuinely cold, S3 has somewhere cheap to park it.
- Governance depth. Object Lock, versioning, replication, IAM condition keys, CloudTrail data events. When an auditor asks for a control, S3 usually has a named feature for it.
Where S3 hurts
- The default configuration is the expensive one. Point image URLs at the bucket endpoint and every byte leaves at the internet rate with nothing cached in front of it. The cheap path has to be built deliberately.
- Four meters instead of two: storage, bucket requests, CDN requests, CDN data transfer, plus per-feature charges. Forecasting takes real effort.
- Geography changes the rate. Delivery costs more per gigabyte to some regions than others, so an audience shift moves your bill without you changing anything.
- Leaving costs money. Copying a large library out is billed transfer, once, in a lump. Usually small against ongoing savings, but it is a real number somebody has to approve.
How to decide between Cloudflare R2 vs Amazon S3
Four levers move the outcome. Everything else is noise.
Cache hit ratio
The dominant term on both platforms, and the one people skip. At a high hit ratio the origin barely gets touched and both bills collapse toward the storage line. At a low hit ratio, R2 bills read operations and S3 bills origin GETs, and both climb with traffic. Media should be trivially cacheable because the files never change. If yours is not caching, that is a configuration problem worth fixing before you pick a vendor, because fixing it changes the answer.
Object count, not library size
WordPress does not store one file per upload. It stores the original plus every registered image size, and themes and page builders happily register more. Ten thousand uploads can easily be sixty thousand objects, each one a write on migration and a read on a cache miss. Count them before you model anything:
cd /path/to/wordpress
# Objects you are about to create in the bucket
find wp-content/uploads -type f | wc -l
# Bytes
du -sh wp-content/uploads
# How much of that count is derivative sizes rather than originals
find wp-content/uploads -type f -regextype posix-extended
-regex '.*-[0-9]+x[0-9]+.(jpe?g|png|webp|avif)$' | wc -l
That last number is usually the surprise. If most of your object count is thumbnails, trimming unused registered sizes before migrating is the cheapest optimization available, and it shrinks writes, reads, and storage at once.
How cold the library actually is
Most libraries are a long tail. A small set of recent files takes nearly all the traffic while the rest sits untouched for years. If that tail is large, S3 has somewhere genuinely cheap to put it and R2 does not. Be careful with R2’s Infrequent Access class here: it lowers the storage rate but raises both operation rates, adds a retrieval fee, and enforces a minimum duration whether you keep the object or not. It fits write-once, read-almost-never data, not a library anything still links to.
What the rest of your stack already is
If your application already runs in AWS and generates derivatives with Lambda on upload, moving to R2 means rebuilding that pipeline against Workers or R2 event notifications. That is real work you do not get paid for. If your site is a VPS from somewhere like InterServer or Contabo with Cloudflare already in front of it, R2 is nearly free work: the DNS is there, the cache is there, and the bucket slots in behind a subdomain you already control.
Setup details that change the answer
A few configuration choices matter more than the vendor choice. Get these wrong and the cheaper platform produces the bigger bill.
Serve from a custom domain, always
On R2 this is the difference between a cached, protected asset host and a throttled test endpoint. Attach one from the dashboard or from Wrangler:
npx wrangler r2 bucket domain add my-media-bucket
--domain=cdn.example.com
--zone-id=<YOUR_ZONE_ID>
npx wrangler r2 bucket domain list my-media-bucket
Then disable public access on the r2.dev subdomain, or you have left a second uncached, unprotected door into the same objects.
Verify the cache instead of assuming it
Assumptions about caching are where the money leaks. Ask the edge directly: request the same object twice and read the headers.
# Cloudflare in front of R2
curl -sI https://cdn.example.com/2024/07/photo-1024x768.jpg
| grep -iE 'cf-cache-status|cache-control|age'
# CloudFront in front of S3
curl -sI https://cdn.example.com/2024/07/photo-1024x768.jpg
| grep -iE 'x-cache|cache-control|age'
First request, expect a miss. Second, expect a hit. If the second still reports a miss, or reports a status meaning the response was never eligible for caching, every image view is costing you an origin read. Fix that before you compare anything.
Long cache lifetimes and versioned filenames
Uploaded media is immutable in practice. Nobody edits the bytes of photo-1024x768.jpg, they upload a new file. So cache lifetimes should be long and the object name should change when the content does. If you find yourself purging image caches regularly, the real problem is that your filenames are not versioned.
On the Cloudflare side, Smart Tiered Cache is worth enabling for R2 origins. It routes edge misses through an upper tier data center close to your bucket instead of letting every edge location fetch independently, which cuts the number of requests reaching R2 at all.
Clean up failed uploads
Large uploads use multipart. Interrupted ones leave orphaned parts that occupy billed storage and do not show up in a normal object listing. Both platforms support lifecycle rules for this, and on R2 you can manage them from Wrangler:
npx wrangler r2 bucket lifecycle list my-media-bucket
npx wrangler r2 bucket lifecycle add my-media-bucket
--name=expire-temp
--prefix=tmp/
--expire-days=30
Configure an abort rule for incomplete multipart uploads on day one, whichever platform you land on. It is the most common source of storage you pay for and cannot see.
A decision procedure you can run this afternoon
- Measure your current cache hit ratio for image paths, from analytics or a sampled read of access logs. Everything downstream depends on this number.
- Count objects, not gigabytes. Run the
findcommands above and separate originals from derivative sizes. - Estimate monthly origin reads as total image requests multiplied by the miss rate. That, not your bandwidth, is what you feed into either pricing calculator.
- Treat writes as a one-off migration spike plus a modest steady rate. Migration is usually the largest write event the bucket ever sees.
- Price both against current published rates on the same day, using Cloudflare’s R2 calculator and the AWS pricing calculator. Rates move. Do not trust a number copied from a blog post, including this one.
- Test with a prefix before committing. Offload one year of uploads, point the site at it, watch cache status headers and the operations dashboard for a week. A week of real traffic beats any spreadsheet.
If it comes out close, take R2. Not because it is cheaper in that scenario, but because it has fewer meters to reason about, and a simpler operational model is worth something on the day something breaks.
Arguments that don’t survive contact
“S3 charges egress, R2 doesn’t, so R2 always wins”
Only true against the worst possible S3 setup. Transfer from an S3 bucket to CloudFront is not billed, so a properly built S3 media stack pays CDN delivery rates on cache misses, not the direct-from-bucket rate. Add CloudFront’s permanent free allowance and a small site pays nothing on either platform. The gap opens at scale and on origin-heavy traffic, not on the first terabyte.
“R2 is a CDN”
R2 is object storage that can sit behind a CDN you already have. The caching, the WAF, the bot rules all come from the Cloudflare zone, and none of it applies to the development URL. If the bucket is not behind a custom domain on a zone you control, you have storage without delivery.
“S3 compatible means drop-in”
It means most tools work with a changed endpoint and a region value of auto. It does not mean every operation, header, and checksum behaves identically. Budget an afternoon for fighting an SDK default, and test uploads, multipart uploads, deletes, and signed URLs against the real bucket before cutting over.
“We’ll save money moving cold files to Infrequent Access”
Sometimes. The lower storage rate arrives with higher operation rates, a retrieval fee, and a minimum duration. If files are cold enough to justify that, they are probably cold enough to belong in a real archive tier, which R2 does not have. Model it against your read pattern rather than assuming a cheaper per-gigabyte number is a cheaper bill.
Frequently asked questions
Is Cloudflare R2 cheaper than S3 for a WordPress media library?
Usually, once traffic is meaningful and the library is served publicly. For a small site behind a well configured CDN, both can land near zero. R2’s advantage grows with egress volume and with the number of requests that miss cache, which is why measuring hit ratio comes first.
Can I use the same WordPress plugin for both?
Several plugins speak the S3 API and support both, including Media Cloud, Advanced Media Offloader, and Next3 Offload. WP Offload Media officially lists Amazon S3, DigitalOcean Spaces, and Google Cloud Storage, so check current provider support before assuming a swap is free. Whichever you pick, confirm it rewrites existing URLs and not only new uploads.
How do I migrate an existing S3 bucket to R2 without downtime?
Cloudflare offers two paths. Super Slurper copies everything in bulk. Sippy migrates incrementally, pulling an object from the source the first time it is requested and serving from R2 afterward. Both are free to use, you pay for the operations they perform against R2, and your source bucket may charge you for the reads.
Do I still need a CDN in front of R2?
You need a custom domain on a Cloudflare zone, which is what puts the CDN in front of it. That is not optional for production. The development URL is rate limited by design and gets no cache, WAF, or bot management.
Can I keep S3 and just put Cloudflare in front of it?
You can, and it does cut origin reads. Be aware that cache misses then pull from S3 across the public internet, billed as ordinary S3 egress, unlike an S3 origin sitting behind CloudFront. Reasonable as an interim step, rarely the cheapest end state.
What about Backblaze B2, Wasabi, or DigitalOcean Spaces?
All viable and worth pricing if you are already modelling. B2 competes on raw storage, Wasabi sells predictability, Spaces is convenient when your droplets are already there. The analysis transfers directly: find the meters, find your cache hit ratio, multiply.
How do I monitor the bill after migrating?
Watch operations and cache hit ratio, not storage. Storage grows slowly and predictably. Operations track traffic and configuration mistakes, which is where the unpleasant surprises live. A Grafana Cloud dashboard or a scheduled pull from the provider’s usage API is enough; the point is that somebody looks weekly rather than at invoice time.
Conclusion
If you take one thing from this Cloudflare R2 vs Amazon S3 comparison, take this: the vendor choice is second order. What decides your media offloading bill is how many requests reach the bucket, and that is a function of your cache configuration, your object count, and whether you put a real domain in front of the storage.
Fix the cache first. Count the objects. Then price both, on the same day, against your own numbers. Most of the time R2 comes out ahead for public media, S3 comes out ahead when the library is entangled with AWS services or needs a genuine archive tier, and the difference is smaller than the internet suggests.
Need help moving a media library without breaking it?
Media offloading looks like a plugin install and turns into a URL rewriting, caching, and permissions problem. I work with teams on the parts that are easy to get subtly wrong:
- Modelling R2 against S3 using your real object counts, request volumes, and cache hit ratio instead of a generic calculator
- Planning and running the migration: bulk or incremental copy, URL rewriting, and a rollback path if the cutover misbehaves
- Custom domain, cache rule, and header configuration so origin reads collapse instead of tracking your traffic
- Bucket permissions, CORS, signed URL flows for private downloads, and lifecycle rules for orphaned multipart uploads
- Trimming registered image sizes and derivative sprawl before migration so you stop paying to store thumbnails nothing links to
- Dashboards for operations, cache hit ratio, and storage growth so the bill stops being a monthly surprise
Send me a curl -I of one of your image URLs and a rough object count, and I can usually tell you quickly whether you have a pricing problem or a caching problem.