<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Cache Control | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/cache-control/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/cache-control/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sun, 20 Sep 2026 12:53:00 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Cache Control | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/cache-control/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</title>
		<link>https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 10 Sep 2026 06:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Amazon S3]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Residency]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[Presigned URLs]]></category>
		<category><![CDATA[S3 Lifecycle Rules]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Vendor Lock-In]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=382</guid>

					<description><![CDATA[<p>Free egress is not free serving. R2 still bills every read, S3 does not bill origin transfer to CloudFront, and the variable that decides both bills is cache hit ratio. An evenhanded comparison of Cloudflare R2 vs Amazon S3 for media offloading, with the four levers that move the number and a decision procedure you can run against your own traffic.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">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.</p>



<p class="wp-block-paragraph">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.</p>



<p class="wp-block-paragraph">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.</p>



<h2 class="wp-block-heading">Free egress is not free serving</h2>



<p class="wp-block-paragraph">R2 charges nothing to move bytes out, on any storage class. It still charges for the read. Every <code>GetObject</code> and every <code>HeadObject</code> is a Class B operation, and a media library made of thousands of small files racks those up fast. Cloudflare&#8217;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.</p>



<p class="wp-block-paragraph">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.</p>



<p class="wp-block-paragraph">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.</p>



<p class="wp-block-paragraph">There is a sharper version on the R2 side. A new bucket gives you a public URL on an <code>r2.dev</code> 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 <code>429 Too Many Requests</code>, 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.</p>



<h2 class="wp-block-heading">Cloudflare R2: where it wins and where it hurts</h2>



<h3 class="wp-block-heading">Where R2 wins</h3>



<ul class="wp-block-list">
<li><strong>Egress really is zero.</strong> 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.</li>

<li><strong>A custom domain puts it behind the CDN.</strong> One managed CNAME and reads start being absorbed at the edge instead of hitting the bucket. No second product to buy and wire up.</li>

<li><strong>Deletes are free operations</strong>, along with aborting a multipart upload. Cleaning up a messy library costs nothing.</li>

<li><strong>Unauthorized requests are not billed.</strong> A caller without permission gets a 401 and you are not charged, which matters once someone starts hammering your bucket path.</li>

<li><strong>Migration tooling is free to use.</strong> Super Slurper copies a bucket across in bulk, Sippy migrates lazily on first miss. You pay only for the operations they perform against R2.</li>
</ul>



<h3 class="wp-block-heading">Where R2 hurts</h3>



<ul class="wp-block-list">
<li><strong>S3 compatible is not S3 identical.</strong> 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.</li>

<li><strong>There is no cold archive tier.</strong> 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.</li>

<li><strong>Data lives in one primary location.</strong> 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.</li>

<li><strong>Plugin support is thinner.</strong> 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.</li>
</ul>



<h2 class="wp-block-heading">Amazon S3: where it wins and where it hurts</h2>



<h3 class="wp-block-heading">Where S3 wins</h3>



<ul class="wp-block-list">
<li><strong>Origin transfer to CloudFront is not billed.</strong> 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.</li>

<li><strong>The CloudFront free allowance is permanent</strong>, covering a monthly volume of data transfer out and requests rather than expiring after twelve months. Small sites can sit inside it indefinitely.</li>

<li><strong>Event driven derivatives are mature.</strong> 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.</li>

<li><strong>A full storage ladder with real lifecycle transitions</strong>, down to deep archive. If a large chunk of your library is genuinely cold, S3 has somewhere cheap to park it.</li>

<li><strong>Governance depth.</strong> 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.</li>
</ul>



<h3 class="wp-block-heading">Where S3 hurts</h3>



<ul class="wp-block-list">
<li><strong>The default configuration is the expensive one.</strong> 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.</li>

<li><strong>Four meters instead of two:</strong> storage, bucket requests, CDN requests, CDN data transfer, plus per-feature charges. Forecasting takes real effort.</li>

<li><strong>Geography changes the rate.</strong> Delivery costs more per gigabyte to some regions than others, so an audience shift moves your bill without you changing anything.</li>

<li><strong>Leaving costs money.</strong> 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.</li>
</ul>



<h2 class="wp-block-heading">How to decide between Cloudflare R2 vs Amazon S3</h2>



<p class="wp-block-paragraph">Four levers move the outcome. Everything else is noise.</p>



<h3 class="wp-block-heading">Cache hit ratio</h3>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">Object count, not library size</h3>



<p class="wp-block-paragraph">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:</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">How cold the library actually is</h3>



<p class="wp-block-paragraph">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&#8217;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.</p>



<h3 class="wp-block-heading">What the rest of your stack already is</h3>



<p class="wp-block-paragraph">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.</p>



<h2 class="wp-block-heading">Setup details that change the answer</h2>



<p class="wp-block-paragraph">A few configuration choices matter more than the vendor choice. Get these wrong and the cheaper platform produces the bigger bill.</p>



<h3 class="wp-block-heading">Serve from a custom domain, always</h3>



<p class="wp-block-paragraph">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:</p>



<pre class="wp-block-code"><code>npx wrangler r2 bucket domain add my-media-bucket 
  --domain=cdn.example.com 
  --zone-id=&lt;YOUR_ZONE_ID&gt;

npx wrangler r2 bucket domain list my-media-bucket</code></pre>



<p class="wp-block-paragraph">Then disable public access on the <code>r2.dev</code> subdomain, or you have left a second uncached, unprotected door into the same objects.</p>



<h3 class="wp-block-heading">Verify the cache instead of assuming it</h3>



<p class="wp-block-paragraph">Assumptions about caching are where the money leaks. Ask the edge directly: request the same object twice and read the headers.</p>



<pre class="wp-block-code"><code># 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'</code></pre>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">Long cache lifetimes and versioned filenames</h3>



<p class="wp-block-paragraph">Uploaded media is immutable in practice. Nobody edits the bytes of <code>photo-1024x768.jpg</code>, 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.</p>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">Clean up failed uploads</h3>



<p class="wp-block-paragraph">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:</p>



<pre class="wp-block-code"><code>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</code></pre>



<p class="wp-block-paragraph">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.</p>



<h2 class="wp-block-heading">A decision procedure you can run this afternoon</h2>



<ol class="wp-block-list">
<li><strong>Measure your current cache hit ratio</strong> for image paths, from analytics or a sampled read of access logs. Everything downstream depends on this number.</li>

<li><strong>Count objects, not gigabytes.</strong> Run the <code>find</code> commands above and separate originals from derivative sizes.</li>

<li><strong>Estimate monthly origin reads</strong> as total image requests multiplied by the miss rate. That, not your bandwidth, is what you feed into either pricing calculator.</li>

<li><strong>Treat writes as a one-off migration spike</strong> plus a modest steady rate. Migration is usually the largest write event the bucket ever sees.</li>

<li><strong>Price both against current published rates</strong> on the same day, using Cloudflare&#8217;s R2 calculator and the AWS pricing calculator. Rates move. Do not trust a number copied from a blog post, including this one.</li>

<li><strong>Test with a prefix before committing.</strong> 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.</li>
</ol>



<p class="wp-block-paragraph">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.</p>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<h3 class="wp-block-heading">&#8220;S3 charges egress, R2 doesn&#8217;t, so R2 always wins&#8221;</h3>



<p class="wp-block-paragraph">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&#8217;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.</p>



<h3 class="wp-block-heading">&#8220;R2 is a CDN&#8221;</h3>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">&#8220;S3 compatible means drop-in&#8221;</h3>



<p class="wp-block-paragraph">It means most tools work with a changed endpoint and a region value of <code>auto</code>. 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.</p>



<h3 class="wp-block-heading">&#8220;We&#8217;ll save money moving cold files to Infrequent Access&#8221;</h3>



<p class="wp-block-paragraph">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.</p>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is Cloudflare R2 cheaper than S3 for a WordPress media library?</h3>



<p class="wp-block-paragraph">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&#8217;s advantage grows with egress volume and with the number of requests that miss cache, which is why measuring hit ratio comes first.</p>



<h3 class="wp-block-heading">Can I use the same WordPress plugin for both?</h3>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">How do I migrate an existing S3 bucket to R2 without downtime?</h3>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">Do I still need a CDN in front of R2?</h3>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">Can I keep S3 and just put Cloudflare in front of it?</h3>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">What about Backblaze B2, Wasabi, or DigitalOcean Spaces?</h3>



<p class="wp-block-paragraph">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.</p>



<h3 class="wp-block-heading">How do I monitor the bill after migrating?</h3>



<p class="wp-block-paragraph">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&#8217;s usage API is enough; the point is that somebody looks weekly rather than at invoice time.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">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.</p>



<p class="wp-block-paragraph">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.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help moving a media library without breaking it?</h2>



<p class="wp-block-paragraph">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:</p>



<ul class="wp-block-list">
<li>Modelling R2 against S3 using your real object counts, request volumes, and cache hit ratio instead of a generic calculator</li>

<li>Planning and running the migration: bulk or incremental copy, URL rewriting, and a rollback path if the cutover misbehaves</li>

<li>Custom domain, cache rule, and header configuration so origin reads collapse instead of tracking your traffic</li>

<li>Bucket permissions, CORS, signed URL flows for private downloads, and lifecycle rules for orphaned multipart uploads</li>

<li>Trimming registered image sizes and derivative sprawl before migration so you stop paying to store thumbnails nothing links to</li>

<li>Dashboards for operations, cache hit ratio, and storage growth so the bill stops being a monthly surprise</li>
</ul>



<p class="wp-block-paragraph">Send me a <code>curl -I</code> 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.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/cloudflare-r2-vs-amazon-s3-media-offloading/">Cloudflare R2 vs Amazon S3 for Media Offloading: Where the Bill Actually Comes From</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Optimizing API Calls to Reduce SaaS Costs: Six Levers That Actually Move the Bill</title>
		<link>https://john-nessime.com/blog/devops/reduce-saas-api-costs/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 14 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[API Cost Optimization]]></category>
		<category><![CDATA[Batch Processing]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Cache Stampede]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Conditional Requests]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[ETag]]></category>
		<category><![CDATA[Exponential Backoff]]></category>
		<category><![CDATA[FinOps]]></category>
		<category><![CDATA[HTTP Headers]]></category>
		<category><![CDATA[Idempotency]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Polling]]></category>
		<category><![CDATA[Rate Limiting]]></category>
		<category><![CDATA[Redis]]></category>
		<category><![CDATA[REST API]]></category>
		<category><![CDATA[Retry Budgets]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Vendor Spend]]></category>
		<category><![CDATA[Webhooks]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=229</guid>

					<description><![CDATA[<p>Third-party API spend is the one production signal with no error rate attached to it, which is why it creeps up quietly. This is a working engineer's guide to reducing SaaS API costs by changing the shape of your calls: reading the billing unit before you optimise anything, killing pointless polling with conditional requests and webhooks, collapsing N+1 patterns, caching with stampede protection and per-tenant keys, stopping your own retry amplification, and attributing spend so you can prove the work paid off.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/reduce-saas-api-costs/">Optimizing API Calls to Reduce SaaS Costs: Six Levers That Actually Move the Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The message usually comes from finance, not from monitoring. A vendor line on the card went up again, nobody shipped a feature that month, and now somebody wants an explanation by Friday.</p>



<p class="wp-block-paragraph">Here is the awkward part: nothing was broken. No alert fired, no error rate moved, no dashboard turned red. Third-party API spend is one of the few production signals with no failure mode attached to it. A background job that polls a vendor every thirty seconds and gets back the exact same payload it got last time looks, from the outside, identical to a healthy integration. It just costs money every single time.</p>



<p class="wp-block-paragraph">This post is about how to reduce SaaS API costs by changing the shape of your calls rather than by renegotiating your contract. Six levers, in the order I&#8217;d pull them: read the billing unit, kill pointless polling, collapse N+1 patterns, cache honestly, stop your own retry amplification, and attribute spend so you can tell whether any of it worked.</p>



<h2 class="wp-block-heading">Start with the billing unit, not the call count</h2>



<p class="wp-block-paragraph">The single most common mistake I see is optimising HTTP requests when the vendor doesn&#8217;t bill HTTP requests. Before you touch any code, read the pricing page carefully enough to answer one question: <em>what is the countable thing?</em></p>



<p class="wp-block-paragraph">It varies more than people expect:</p>



<ul class="wp-block-list">
<li><strong>Per request.</strong> The obvious one. Geocoding, enrichment, most REST endpoints.</li>

<li><strong>Per session.</strong> Google Maps Platform bills Places Autocomplete this way when you pass a session token: the whole keystroke stream plus the terminating Place Details call is grouped and billed as one unit. Omit the token, or reuse one across sessions, and every keystroke request is billed individually.</li>

<li><strong>Per token issued.</strong> Auth0&#8217;s machine-to-machine quotas count access tokens obtained through the client credentials flow, not the API calls you make with them. A service that fetches a fresh token on every outbound request is burning the meter for no reason.</li>

<li><strong>Per input and output token.</strong> LLM providers. Output is typically priced higher than input, which makes &#8220;be concise&#8221; a cost control and not just a style note.</li>

<li><strong>Per record, event, or message.</strong> Twilio, SendGrid, most streaming and messaging platforms. One API call can carry many billable units.</li>

<li><strong>Not at all.</strong> Stripe doesn&#8217;t bill you per API call; the constraint there is a rate limit, not a line item. Optimising those calls buys you reliability headroom, not money.</li>
</ul>



<p class="wp-block-paragraph">Getting this wrong sends you down the wrong path for weeks. Batching requests against an endpoint that bills per record changes your request count and leaves the invoice exactly where it was. Read the unit first.</p>



<h2 class="wp-block-heading">Lever one: stop polling for things that rarely change</h2>



<p class="wp-block-paragraph">This is almost always the biggest single line, and it&#8217;s invisible because it&#8217;s steady. A sync job on a five-minute cron is 288 calls a day per resource. Multiply by resources, by environments, by the replicas you quietly scaled to three, and you get a number with no relationship at all to how often the data changed.</p>



<h3 class="wp-block-heading">Conditional requests, when the vendor honours them</h3>



<p class="wp-block-paragraph">If the API returns an <code>ETag</code> or <code>Last-Modified</code> header, store it and send it back on the next poll as <code>If-None-Match</code> or <code>If-Modified-Since</code>. If nothing changed you get a <code>304 Not Modified</code> with an empty body.</p>



<pre class="wp-block-code"><code># First call: capture the ETag from the response headers
curl -sS -D - -o /dev/null 
  -H "Authorization: Bearer $TOKEN" 
  https://api.github.com/repos/OWNER/REPO/pulls

# Second call: send it back and check the status code only
curl -sS -o /dev/null -w '%{http_code}n' 
  -H "Authorization: Bearer $TOKEN" 
  -H 'If-None-Match: "PASTE_THE_ETAG_HERE"' 
  https://api.github.com/repos/OWNER/REPO/pulls</code></pre>



<p class="wp-block-paragraph"><code>-D -</code> dumps response headers to stdout so you can see the <code>etag</code> value. <code>-w '%{http_code}'</code> on the second call prints just the status, which is all you care about: <code>304</code> means the resource is unchanged and you saved a payload.</p>



<p class="wp-block-paragraph">Whether that saves <em>money</em> depends on the vendor. GitHub documents that a conditional request returning 304 doesn&#8217;t count against your primary rate limit when it&#8217;s correctly authorised. Plenty of other vendors return 304 happily and bill it anyway. Test against your own invoice before you assume.</p>



<p class="wp-block-paragraph">Three things that bite people with ETags:</p>



<ul class="wp-block-list">
<li>They&#8217;re per-page on paginated endpoints. A 304 on page one says nothing about pages two through five, so store one ETag per page.</li>

<li>They can be scoped to the credential. Rotate a token and your stored ETags may stop matching, which quietly turns your conditional requests back into full ones.</li>

<li>GraphQL endpoints generally don&#8217;t do conditional requests at all. You cache those yourself, keyed on a hash of the query and its variables.</li>
</ul>



<h3 class="wp-block-heading">Webhooks, when they exist</h3>



<p class="wp-block-paragraph">The proper fix is to stop asking. If the vendor supports webhooks, the call count collapses from &#8220;one per interval&#8221; to &#8220;one per actual change&#8221;, which is usually a difference of two orders of magnitude on a slow-moving resource.</p>



<p class="wp-block-paragraph">Be honest about what it costs, though. Webhooks need a publicly reachable endpoint, signature verification, idempotent handling because vendors redeliver, a dead-letter path, and a reconciliation sweep for deliveries that never arrived. On a resource that changes every few seconds, polling is simpler and the savings are small. On one that changes twice a week, the webhook pays for itself immediately.</p>



<p class="wp-block-paragraph">A middle option people forget: many APIs expose a cheap &#8220;has anything changed&#8221; endpoint, a sync cursor, or a delta token. Poll that on a tight interval and only fetch the expensive payload when it moves.</p>



<h2 class="wp-block-heading">Lever two: collapse the N+1 pattern</h2>



<p class="wp-block-paragraph">You know this one from databases, and it shows up identically against third-party APIs. Fetch a list of 200 items, then fetch each item&#8217;s detail individually: that&#8217;s 201 billable calls where the vendor probably offers a way to do it in one or two. Look for:</p>



<ul class="wp-block-list">
<li><strong>Expansion or embed parameters.</strong> Many REST APIs let you inline related objects in the list response so the detail calls disappear entirely.</li>

<li><strong>Field selection.</strong> Asking for fewer fields is free on per-request billing but matters a lot where the field tier drives the price. Google Places is explicit about this: requesting a single higher-tier field upgrades the whole response to that tier&#8217;s pricing.</li>

<li><strong>Bulk and batch endpoints.</strong> Salesforce, Algolia, most CRMs and search platforms have one. They&#8217;re usually documented in a corner nobody reads.</li>

<li><strong>Larger pages.</strong> If the endpoint accepts a page size of 200 and you&#8217;re defaulting to 25, you&#8217;re paying eight times as many calls to fetch identical data.</li>
</ul>



<p class="wp-block-paragraph">The page-size one is the cheapest win in this entire post and it&#8217;s a one-line change. Check your client library defaults today.</p>



<h2 class="wp-block-heading">Lever three: cache with a boring, honest TTL</h2>



<p class="wp-block-paragraph">Caching third-party responses is the lever everyone reaches for first and gets subtly wrong. Three failure modes matter more than the cache itself.</p>



<p class="wp-block-paragraph"><strong>Stampede.</strong> A popular key expires, fifty concurrent requests all miss, and fifty identical calls go out to the vendor. You just paid fifty times for one refresh, at the worst possible moment.</p>



<p class="wp-block-paragraph"><strong>No negative caching.</strong> A lookup that returns &#8220;not found&#8221; is usually not cached at all, so the same failing lookup hammers the vendor forever. Cache your 404s. Short TTL, but cache them.</p>



<p class="wp-block-paragraph"><strong>Cache key design.</strong> If your key doesn&#8217;t include the credential or tenant, you will eventually serve one customer&#8217;s data to another. This is the one mistake in this post that turns a cost problem into an incident.</p>



<p class="wp-block-paragraph">You don&#8217;t necessarily need application code for this. An Nginx instance in front of the vendor handles all three, and it&#8217;s the approach I reach for first when several services hit the same API:</p>



<pre class="wp-block-code"><code>proxy_cache_path /var/cache/nginx/api levels=1:2 keys_zone=api_cache:20m
                 max_size=1g inactive=60m use_temp_path=off;

server {
    listen 127.0.0.1:8080;

    location / {
        proxy_pass https://api.vendor.example;
        proxy_set_header Host api.vendor.example;

        proxy_cache api_cache;
        proxy_cache_key "$request_method$request_uri$http_authorization";

        proxy_cache_valid 200 5m;
        proxy_cache_valid 404 30s;

        proxy_cache_lock on;
        proxy_cache_use_stale updating error timeout;
        proxy_cache_background_update on;

        add_header X-Cache-Status $upstream_cache_status;
    }
}</code></pre>



<p class="wp-block-paragraph">Reading that from the top down, because every directive is there for a reason:</p>



<ul class="wp-block-list">
<li><code>proxy_cache_key</code> includes <code>$http_authorization</code>, so responses are isolated per credential. That&#8217;s the tenant-leak fix.</li>

<li><code>proxy_cache_valid 404 30s</code> is the negative caching. Failed lookups stop being free traffic for the vendor.</li>

<li><code>proxy_cache_lock on</code> is the stampede fix. Only the first request for a given key goes upstream; the rest wait for it.</li>

<li><code>proxy_cache_use_stale updating</code> plus <code>proxy_cache_background_update on</code> serves the stale copy while a single background request refreshes it. Latency stays flat and you don&#8217;t pay a thundering herd at every expiry.</li>

<li><code>X-Cache-Status</code> gives you <code>HIT</code>, <code>MISS</code>, <code>STALE</code> or <code>UPDATING</code> in the response so you can actually measure your hit rate instead of guessing at it.</li>
</ul>



<p class="wp-block-paragraph">The trade-off is worth stating plainly: you&#8217;re now serving data that can be five minutes stale, and you own another moving part. Fine for a currency rate or a product catalogue. Not fine for an inventory count on a checkout page. Set the TTL from how wrong the data is allowed to be, never from how much you want to save.</p>



<p class="wp-block-paragraph">Where the caller is distributed, Redis with a short lock key does the same job in application code, and a small always-on VPS from something like InterServer or DigitalOcean is plenty to host either the proxy or the cache. At the edge, Cloudflare Workers with KV lets you cache vendor responses close to users, which helps when the calls originate from browsers rather than your backend.</p>



<h2 class="wp-block-heading">Lever four: stop paying for your own retries</h2>



<p class="wp-block-paragraph">This produces the sudden spike rather than the slow creep, and people miss it because retries feel like resilience.</p>



<p class="wp-block-paragraph">The vendor wobbles. Every client retries. With a fixed delay they retry in lockstep, so the vendor gets a synchronised wave on top of normal load, struggles longer, and generates more retries. AWS&#8217;s architecture write-up on exponential backoff and jitter is the canonical reference, and its central finding is worth repeating: backoff alone still leaves visible clusters of calls. Randomising each client&#8217;s delay is what decorrelates the herd, and in their simulation it cut total call count substantially.</p>



<pre class="wp-block-code"><code>import random, time

BASE = 0.5      # seconds
CAP  = 30.0     # never sleep longer than this

def sleep_before_retry(attempt):
    # capped exponential backoff, then full jitter
    window = min(CAP, BASE * (2 ** attempt))
    time.sleep(random.uniform(0, window))</code></pre>



<p class="wp-block-paragraph">The cap stops a long outage turning into hour-long sleeps. The <code>random.uniform(0, window)</code> is the jitter: two processes that failed at the same instant now wake at different times. Note that full jitter can occasionally produce a near-zero delay, which is fine for transient errors but too aggressive for throttling responses that reset on a per-minute window. For a <code>429</code>, honour the <code>Retry-After</code> header if the vendor sends one, and use a jitter formula with a guaranteed floor if they don&#8217;t.</p>



<p class="wp-block-paragraph">Three more rules that cost nothing to implement:</p>



<ol class="wp-block-list">
<li><strong>Never retry a 4xx except 429.</strong> A 400 or a 422 will fail identically forever. Every one of those retries is a billed call buying you nothing.</li>

<li><strong>Cap retries as a fraction of traffic.</strong> A retry budget (say, retries may not exceed some small percentage of total requests) is the only control that sees the system-wide picture. Per-client backoff can&#8217;t.</li>

<li><strong>Check your layers.</strong> If the SDK retries three times, your HTTP client retries three times, and your job runner retries three times, one logical operation is up to twenty-seven calls. This stacking is extremely common and almost never intentional.</li>
</ol>



<p class="wp-block-paragraph">For writes, retrying safely needs an idempotency key so the vendor deduplicates rather than creating two of whatever you asked for. Stripe&#8217;s <code>Idempotency-Key</code> header is the pattern most others copied. Without it, teams disable retries on writes entirely and then eat manual reconciliation work instead.</p>



<h2 class="wp-block-heading">Lever five: move latency-tolerant work off the synchronous path</h2>



<p class="wp-block-paragraph">Ask a blunt question about each integration: does a human wait on this response?</p>



<p class="wp-block-paragraph">Nightly enrichment, classification, report generation, bulk address validation, embedding backfills. Nobody is waiting. Several vendors, including the major LLM providers, offer asynchronous batch endpoints at a discount to the synchronous rate in exchange for a looser delivery window. Rates move, so check current terms rather than a number you read somewhere, but the mechanism is stable and the discount is usually substantial.</p>



<p class="wp-block-paragraph">Even without a batch endpoint, moving work behind a queue lets you coalesce. Ten events touching the same customer record within a minute become one enrichment call instead of ten. That deduplication window is often worth more than the batch discount.</p>



<h2 class="wp-block-heading">Lever six: attribute the spend or you&#8217;re guessing</h2>



<p class="wp-block-paragraph">Everything above is unfalsifiable until you can answer: which feature, which tenant, which job. The invoice gives you the total and never the cause. What I&#8217;d put in place, in order:</p>



<ol class="wp-block-list">
<li><strong>Route outbound calls through one place.</strong> A shared HTTP client wrapper or the caching proxy above. If every service instantiates its own client, you have no chokepoint and no measurement.</li>

<li><strong>Emit a counter per call</strong> with labels for vendor, endpoint, status class, and cache result. Keep the label set small; per-tenant labels will blow up your metrics cardinality and trade one bill for another.</li>

<li><strong>Build one unit-cost metric.</strong> Vendor spend per active tenant per month. It&#8217;s the only number that survives growth, because raw spend going up during a good quarter is not a problem.</li>

<li><strong>Alert on call rate, not on the bill.</strong> A rate alert fires in minutes. An invoice fires in thirty days. Grafana, Prometheus, Datadog, CloudWatch, whatever you already run is fine; the point is that the alert exists at all.</li>

<li><strong>Track cache hit rate as a first-class SLI.</strong> When someone changes a cache key format and the hit rate silently drops to zero, this is the graph that tells you before the invoice does.</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Troubleshooting: the bill jumped and nothing shipped</h2>



<p class="wp-block-paragraph">Work down this list. In my experience the cause is usually in the first four.</p>



<ul class="wp-block-list">
<li><strong>Replica count changed.</strong> Autoscaling or a Kubernetes replica bump multiplies every cron-driven poller. Check whether your scheduled jobs have leader election, or whether all five pods are dutifully polling the same endpoint.</li>

<li><strong>Cache hit rate collapsed.</strong> A changed key format, a Redis eviction policy under memory pressure, or a TTL someone dropped &#8220;temporarily&#8221; during debugging. Graph the hit rate over the period, not the current value.</li>

<li><strong>Retry loop against a persistent 4xx.</strong> Look for a flat, high rate of identical failing calls. This has the distinctive signature of high call volume with zero successful responses.</li>

<li><strong>A non-production environment pointed at production credentials.</strong> Staging, a CI pipeline, or an integration test suite running on every commit. Check whether calls are arriving from IPs you don&#8217;t recognise.</li>

<li><strong>Session or token handling regressed.</strong> A refactor that dropped a session token parameter, or a service that stopped caching its auth token and now mints a new one per request.</li>

<li><strong>Legitimate growth in an expensive segment.</strong> A few large customers using a costly feature move the bill without moving call count much. Not a bug, but worth ruling in before you go hunting for one.</li>
</ul>



<p class="wp-block-paragraph">If the vendor exposes per-key usage breakdowns in their dashboard, split your credentials by service before your next incident. Separate API keys per environment and per major consumer turn a whodunnit into a two-minute lookup.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Optimising the endpoint that&#8217;s easy to change rather than the one that dominates the bill. Rank by spend first.</li>

<li>Caching without a per-credential key, and finding out the hard way in a multi-tenant system.</li>

<li>Treating 304 responses as free when your specific vendor bills them anyway.</li>

<li>Setting TTLs from a savings target rather than a staleness tolerance, and finding out during a customer escalation.</li>

<li>Leaving retry logic enabled at three separate layers and calling the result &#8220;defence in depth&#8221;.</li>

<li>Adding per-tenant labels to outbound-call metrics and moving the cost from the API vendor to the observability vendor.</li>

<li>Shipping the optimisation and never checking the next invoice against the prediction.</li>
</ul>



<h2 class="wp-block-heading">Best practices worth keeping</h2>



<ul class="wp-block-list">
<li>One outbound HTTP client wrapper per service, with timeouts, retries, and metrics configured in exactly one place.</li>

<li>Separate API credentials per environment and per major consumer, so usage dashboards mean something.</li>

<li>Cache auth tokens until shortly before expiry. Refresh early, not on every call.</li>

<li>Add jitter to scheduled jobs as well as to retries. Everything set to run at the top of the hour arrives at the vendor as a spike.</li>

<li>Write down the staleness tolerance for each cached resource next to its TTL, so the next person doesn&#8217;t have to reverse-engineer your reasoning.</li>

<li>Re-check the pricing page after any vendor product update. Billing units change more often than endpoints do.</li>
</ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">How do I find out which service is driving my API spend?</h3>



<p class="wp-block-paragraph">Start with the vendor&#8217;s own usage dashboard broken down by API key. If all your services share one key, that&#8217;s the first thing to fix. Failing that, log every outbound call at the client wrapper with the endpoint and calling service, and aggregate for a week. A week of honest logs beats a month of theorising.</p>



<h3 class="wp-block-heading">Do ETags actually reduce SaaS API costs, or just help with rate limits?</h3>



<p class="wp-block-paragraph">It depends entirely on the vendor. GitHub documents that an authorised conditional request returning 304 doesn&#8217;t count against the primary rate limit. Others count every request regardless of status. Run a controlled test: a known number of conditional calls over a billing period, then compare against the usage report. Don&#8217;t assume either way.</p>



<h3 class="wp-block-heading">Is caching third-party API responses allowed?</h3>



<p class="wp-block-paragraph">Usually yes, but not always, and some terms of service restrict how long you may store certain data. Mapping and place data is a common example of restricted caching. Check the terms for the specific vendor before you build a permanent store, particularly if you&#8217;re caching anything personally identifiable.</p>



<h3 class="wp-block-heading">How much can caching realistically cut an API bill?</h3>



<p class="wp-block-paragraph">Nobody can give you a number without knowing your traffic. Estimate it yourself: for a candidate endpoint, count distinct request keys versus total requests over a day. That ratio is roughly your ceiling for hit rate. Multiply by the endpoint&#8217;s share of spend. If the answer is a couple of percent, spend your time elsewhere.</p>



<h3 class="wp-block-heading">Should I always replace polling with webhooks?</h3>



<p class="wp-block-paragraph">No. The comparison is change frequency against poll frequency. If the resource changes almost as often as you poll, you save little and take on signature verification, redelivery handling, and a reconciliation job. Webhooks win decisively on slow-changing resources and on anything where you&#8217;re polling more than a handful of objects.</p>



<h3 class="wp-block-heading">Does rate limiting my own users reduce vendor costs?</h3>



<p class="wp-block-paragraph">Only when user actions map directly to vendor calls, such as a search box that fires an upstream request per keystroke. Debouncing input and enforcing per-plan quotas helps a lot there. For background processing driven by your own schedules, user rate limits change nothing.</p>



<h3 class="wp-block-heading">Should I disable retries to save money?</h3>



<p class="wp-block-paragraph">No. Fix them instead. Capped exponential backoff with jitter, no retries on non-429 client errors, a retry budget, and retry logic living at exactly one layer. Disabling retries entirely trades a cost problem for a reliability problem, and reliability problems are more expensive.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Most efforts to reduce SaaS API costs fail because they start with code. They should start with two questions: what is the vendor actually counting, and which of my calls returned information I already had?</p>



<p class="wp-block-paragraph">Answer those honestly and the work usually collapses to a handful of unglamorous changes. Raise a page size. Cache a token. Put a lock around a cache refresh. Delete a poller that was watching something that changes twice a week. None of it is clever. All of it shows up on the next invoice, which is the only review that counts.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help getting your API spend under control?</h2>



<p class="wp-block-paragraph">This is work I take on regularly. Typical engagements look like:</p>



<ul class="wp-block-list">
<li>Auditing outbound API traffic to find which endpoints, jobs and tenants are driving the bill, and ranking them by spend rather than by how obvious they are.</li>

<li>Building a caching layer in front of chatty vendor APIs, with Nginx, Redis or Cloudflare Workers, including stampede protection and per-tenant key isolation.</li>

<li>Replacing polling jobs with webhook receivers or delta-token syncs, with signature verification, idempotent handling and a dead-letter path.</li>

<li>Auditing timeout and retry configuration across services to remove stacked retries and add proper backoff, jitter and retry budgets.</li>

<li>Moving latency-tolerant workloads onto batch or queue-based paths, with deduplication windows that cut call volume before it reaches the vendor.</li>

<li>Setting up call-rate and cache-hit-rate dashboards and alerts in Prometheus, Grafana, Datadog or CloudWatch so the invoice is never the first signal.</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got a cost graph that&#8217;s climbing, a job you suspect is over-polling, or a client wrapper you&#8217;re not sure about, send it over and I&#8217;ll tell you what I&#8217;d look at first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/reduce-saas-api-costs/">Optimizing API Calls to Reduce SaaS Costs: Six Levers That Actually Move the Bill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>CDN Setup Without Breaking Dynamic Content</title>
		<link>https://john-nessime.com/blog/devops/cdn-setup-dynamic-content/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 22:51:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Cache Control]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[Edge Computing]]></category>
		<category><![CDATA[HTTP Headers]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=49</guid>

					<description><![CDATA[<p>Put a CDN in front of a dynamic site carelessly and it will cache one user's personalised page and serve it to everyone — invisibly, because your logged-in browser looks fine. Here is how to classify routes, set cache headers that hold, and run the two curl commands that catch the leak.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/cdn-setup-dynamic-content/">CDN Setup Without Breaking Dynamic Content</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Somebody puts a CDN in front of the site on a Friday. The homepage gets faster, the bill goes down, everyone is pleased. On Monday a customer emails to say they logged in and saw someone else&#8217;s name in the header.</p>



<p class="wp-block-paragraph">That is the failure mode worth taking seriously. Not slowness, not downtime — the CDN cached a page that was personalised for one user and served it to everybody else. And you will not notice, because your browser holds a session and looks fine. The people seeing the broken version are the ones who never contact you.</p>



<p class="wp-block-paragraph">Getting CDN setup right for a dynamic site is not about finding the right toggle in a dashboard. It is about deciding, per route, what is public and what is personal, and then making the origin say so clearly enough that no edge node has to guess.</p>



<h2 class="wp-block-heading">Sort your routes before touching any config</h2>



<p class="wp-block-paragraph">Every URL on your site falls into one of three buckets. Write the list out — actually write it, this takes ten minutes and prevents the incident above.</p>



<ul class="wp-block-list">
<li><strong>Static assets.</strong> CSS, JS, images, fonts. Identical for everyone, content-addressed if your build is sensible. Cache these hard, for a year.</li>

<li><strong>Public dynamic.</strong> Generated by the application but identical for every anonymous visitor. Blog posts, product pages, category listings. This is where the real performance win lives, and where most people leave everything uncached out of nervousness.</li>

<li><strong>Private dynamic.</strong> Anything personalised or authenticated. Carts, dashboards, checkout, admin, most API responses. Never cached at a shared cache, no exceptions.</li>
</ul>



<p class="wp-block-paragraph">The middle bucket is the whole game. If you cache only the first bucket you have built an asset CDN and left the expensive work untouched. If you accidentally cache the third, you have a data leak.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The header that separates the buckets</h2>



<p class="wp-block-paragraph">The single most useful thing to understand is the difference between <code>private</code> and <code>no-store</code>, because people use them interchangeably and they mean very different things.</p>



<pre class="wp-block-code"><code># Static assets with a content hash in the filename
Cache-Control: public, max-age=31536000, immutable

# Public dynamic: edge caches it, browser revalidates
Cache-Control: public, s-maxage=300, max-age=0, must-revalidate

# Personalised: browser may cache, shared caches must not
Cache-Control: private, no-store

# Genuinely sensitive: nobody stores this anywhere
Cache-Control: no-store</code></pre>



<p class="wp-block-paragraph"><code>private</code> means &#8220;a browser cache may keep this, a shared cache may not&#8221;. <code>no-store</code> means &#8220;do not write this to disk anywhere&#8221;. For an authenticated dashboard you want both. For a bank statement you want <code>no-store</code> alone.</p>



<p class="wp-block-paragraph">The other key pair is <code>s-maxage</code> versus <code>max-age</code>. <code>s-maxage</code> applies only to shared caches — the CDN — and overrides <code>max-age</code> there. That lets you cache a product page at the edge for five minutes while telling browsers to revalidate every time, so a price change propagates in five minutes rather than living in someone&#8217;s browser for an hour.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">Set caching headers at the origin, per route, and let the CDN follow them. Configuring cache rules only in the CDN dashboard means the policy lives somewhere your application code cannot see and your staging environment does not have.</p>
</blockquote>



<h2 class="wp-block-heading">Vary, and how it quietly ruins your hit rate</h2>



<p class="wp-block-paragraph"><code>Vary</code> tells caches that the response depends on a request header, so a separate copy is stored per distinct value. Used precisely it is essential. Used carelessly it destroys your cache.</p>



<pre class="wp-block-code"><code># Fine. A handful of encodings, a handful of variants.
Vary: Accept-Encoding

# Also fine if you genuinely serve different content per language
Vary: Accept-Encoding, Accept-Language</code></pre>



<p class="wp-block-paragraph">Now the one to avoid:</p>



<pre class="wp-block-code"><code># Effectively disables caching. Every browser build is a distinct
# User-Agent string, so every visitor gets their own cache entry.
Vary: User-Agent</code></pre>



<p class="wp-block-paragraph">If you need device-specific responses, most CDNs expose a normalised device-type value with two or three possible values. Use that rather than the raw header.</p>



<p class="wp-block-paragraph"><code>Vary: Cookie</code> deserves its own warning. It looks like a safe way to separate logged-in from anonymous traffic, and it is not — every visitor carries slightly different cookies from analytics and consent tools, so you get a near-unique cache key per person. The correct approach is to make the presence of a session cookie bypass the cache entirely, rather than varying on it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Cookies are where sites actually break</h2>



<p class="wp-block-paragraph">Two rules, and between them they prevent most CDN incidents.</p>



<h3 class="wp-block-heading">Rule one: a session cookie means bypass</h3>



<p class="wp-block-paragraph">If a request arrives carrying your session cookie, it should go to origin uncached. Most CDNs let you express this as a cache rule; in nginx, if you are running your own edge, it is a variable:</p>



<pre class="wp-block-code"><code>map $http_cookie $bypass_cache {
    default                 0;
    "~*wordpress_logged_in" 1;
    "~*woocommerce_items"   1;
    "~*comment_author"      1;
    "~*PHPSESSID"           1;
}

location / {
    proxy_cache            edge_cache;
    proxy_cache_bypass     $bypass_cache;   # do not serve from cache
    proxy_no_cache         $bypass_cache;   # do not write to cache
    proxy_cache_valid      200 301 302 5m;
    proxy_cache_use_stale  error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_lock       on;

    add_header X-Cache-Status $upstream_cache_status always;
    proxy_pass http://origin;
}</code></pre>



<p class="wp-block-paragraph">You need <em>both</em> directives. <code>proxy_cache_bypass</code> stops a logged-in user being served a cached anonymous page. <code>proxy_no_cache</code> stops their personalised response being written into the cache for everyone else. Setting only the first is a common and dangerous mistake — the read path is protected while the write path is wide open.</p>



<p class="wp-block-paragraph">That <code>X-Cache-Status</code> header is not decoration. It is how you verify any of this actually works.</p>



<h3 class="wp-block-heading">Rule two: strip cookies you do not need</h3>



<p class="wp-block-paragraph">Analytics and consent tools set cookies on every visitor. If your CDN treats any cookie as a bypass signal, your anonymous traffic stops being cacheable the moment you add a tag manager. Configure the CDN to ignore everything except the specific cookies that matter, and be explicit about which those are.</p>



<h2 class="wp-block-heading">Query strings, and the cache-key explosion</h2>



<p class="wp-block-paragraph">By default many CDNs include the full query string in the cache key. Then a marketing campaign starts appending <code>utm_source</code>, <code>utm_medium</code>, <code>fbclid</code> and friends, and one page becomes thousands of cache entries that each miss exactly once.</p>



<p class="wp-block-paragraph">Decide deliberately which parameters affect the response:</p>



<ul class="wp-block-list">
<li><strong>Include</strong> parameters that change the content — <code>?page=2</code>, <code>?sort=price</code>, <code>?category=boots</code>.</li>

<li><strong>Ignore</strong> tracking parameters entirely. They never change what the server renders.</li>

<li><strong>Normalise order</strong> where the CDN supports it, so <code>?a=1&amp;b=2</code> and <code>?b=2&amp;a=1</code> are one entry rather than two.</li>
</ul>



<p class="wp-block-paragraph">An allowlist is safer than a blocklist here. New tracking parameters appear constantly; the set of parameters your application actually reads changes rarely.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Keeping dynamic pages cached without going stale</h2>



<p class="wp-block-paragraph">The usual objection to caching public dynamic pages is that content changes. Two mechanisms solve this, and together they let you cache aggressively without serving stale content for long.</p>



<h3 class="wp-block-heading">Stale-while-revalidate</h3>



<pre class="wp-block-code"><code>Cache-Control: public, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400</code></pre>



<p class="wp-block-paragraph">Read that as three separate promises. Fresh for sixty seconds. For the following ten minutes, serve the stale copy immediately while fetching a fresh one in the background — so nobody waits. And if the origin is throwing errors, keep serving the stale copy for a day rather than showing a 502.</p>



<p class="wp-block-paragraph">That last directive turns your CDN into a partial outage shield. It is one of the highest-value headers on this page and it costs nothing.</p>



<h3 class="wp-block-heading">Purge on change</h3>



<p class="wp-block-paragraph">Time-based expiry is a blunt instrument. If your application knows when a product price changed, tell the CDN immediately rather than waiting for a TTL.</p>



<p class="wp-block-paragraph">Tag-based purging is the version worth building toward — tag responses by the entities they contain, then purge by tag when an entity changes:</p>



<pre class="wp-block-code"><code># Origin response for a product page
Cache-Tag: product-1234, category-boots, layout-main</code></pre>



<p class="wp-block-paragraph">Update product 1234 and you purge one tag, invalidating the product page, every category listing it appears on, and the homepage if it is featured — without knowing any of those URLs in advance. Header name and support vary by provider, so check what yours calls it.</p>



<h2 class="wp-block-heading">Verifying it before you point DNS</h2>



<p class="wp-block-paragraph">Test with curl, not a browser. Your browser has cookies, a warm local cache and an opinion; curl tells you the truth.</p>



<pre class="wp-block-code"><code># What headers is the origin actually sending?
curl -sI https://example.com/products/boots | grep -i 'cache-control|vary|set-cookie'

# Twice in a row: second request should show a HIT
curl -sI https://example.com/products/boots | grep -i 'x-cache|age'
curl -sI https://example.com/products/boots | grep -i 'x-cache|age'</code></pre>



<p class="wp-block-paragraph">Now the test that matters most. Request an authenticated page with a real session cookie and confirm it never returns a cache hit:</p>



<pre class="wp-block-code"><code>curl -sI https://example.com/account 
  -H "Cookie: session=&lt;a-real-session-value&gt;" 
  | grep -i 'x-cache|cache-control'</code></pre>



<p class="wp-block-paragraph">You want <code>BYPASS</code> or <code>MISS</code>, and <code>Cache-Control: private</code>. If that ever shows <code>HIT</code>, stop and fix it before going further.</p>



<p class="wp-block-paragraph">The leak test, which is the one people skip: fetch a personalised page with a session, then fetch the same URL with no cookies at all and confirm you get the anonymous version.</p>



<pre class="wp-block-code"><code># With a session
curl -s https://example.com/account -H "Cookie: session=&lt;value&gt;" | grep -i 'welcome back'

# Immediately after, with nothing. Should NOT show the personalised content.
curl -s https://example.com/account | grep -i 'welcome back'</code></pre>



<p class="wp-block-paragraph">If the second command finds personalised content, the CDN cached a private response. That is the Monday-morning email, and you have just caught it before your customers did.</p>



<h3 class="wp-block-heading">Test the edge before DNS points at it</h3>



<pre class="wp-block-code"><code>curl -sI https://example.com/ --resolve example.com:443:&lt;edge-ip&gt;</code></pre>



<p class="wp-block-paragraph">That sends a real request to a specific edge node with the correct hostname and TLS SNI, without changing DNS. It is how you find out whether the whole thing works before any user does.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Things that break in ways nobody expects</h2>



<h3 class="wp-block-heading">Client IPs all become the CDN&#8217;s</h3>



<p class="wp-block-paragraph">Your logs fill with edge node addresses, rate limiting starts throttling everyone at once, and geolocation breaks. The real address arrives in a forwarded header, and your origin has to be configured to trust it — but only from the CDN&#8217;s address ranges. Trusting <code>X-Forwarded-For</code> from anywhere lets any visitor claim any IP they like, which turns a logging annoyance into an authentication bypass.</p>



<h3 class="wp-block-heading">Redirect loops</h3>



<p class="wp-block-paragraph">The CDN talks to your origin over HTTP, the origin sees a non-HTTPS request and redirects to HTTPS, which comes back through the CDN, forever. Either terminate TLS at the origin too, or have the origin trust <code>X-Forwarded-Proto</code> when deciding whether to redirect.</p>



<h3 class="wp-block-heading">POST responses getting cached</h3>



<p class="wp-block-paragraph">Shouldn&#8217;t happen by default, but a badly written cache rule can do it, and the result is one user&#8217;s form submission response served to the next person. Only cache <code>GET</code> and <code>HEAD</code>. Ever.</p>



<h3 class="wp-block-heading">Websockets and streaming</h3>



<p class="wp-block-paragraph">Websocket upgrades and server-sent events need to pass through untouched. Buffering breaks streaming responses silently — the connection works, the data just arrives in one lump at the end, which looks like an application bug rather than a proxy setting.</p>



<h3 class="wp-block-heading">Set-Cookie on a cacheable response</h3>



<p class="wp-block-paragraph">If a public page returns <code>Set-Cookie</code> and gets cached, every subsequent visitor receives that cookie — potentially somebody&#8217;s session identifier. Most CDNs refuse to cache responses carrying <code>Set-Cookie</code>, but not all, and not always. Check your origin is not setting cookies on pages you intend to cache.</p>



<h2 class="wp-block-heading">Rolling it out without a bad week</h2>



<ol class="wp-block-list">
<li><strong>Write down the route list.</strong> Three buckets, every path accounted for.</li>

<li><strong>Set headers at the origin first,</strong> with the CDN not yet in front. Verify with curl.</li>

<li><strong>Start with everything bypassed</strong> except static assets. A CDN that caches nothing still terminates TLS closer to users and is not a failure.</li>

<li><strong>Add public dynamic routes one at a time,</strong> with short TTLs. Sixty seconds, then extend once you trust it.</li>

<li><strong>Run the leak test</strong> after every rule change, not just at the end.</li>

<li><strong>Watch origin request volume.</strong> It should drop noticeably. If it does not, something is bypassing that you think is caching.</li>

<li><strong>Keep a purge command to hand</strong> and know how long a full purge takes before you need it.</li>
</ol>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Testing only in a logged-in browser, where cached-private-content bugs are invisible.</li>

<li>Setting <code>proxy_cache_bypass</code> without <code>proxy_no_cache</code>, protecting reads but not writes.</li>

<li><code>Vary: Cookie</code> or <code>Vary: User-Agent</code>, which shatters the cache into near-unique entries.</li>

<li>Leaving tracking parameters in the cache key.</li>

<li>Configuring caching in the CDN dashboard only, so the policy is invisible to your code and absent from staging.</li>

<li>Trusting forwarded IP headers from any source rather than only the CDN&#8217;s ranges.</li>

<li>Using <code>private</code> where you needed <code>no-store</code>, or the reverse.</li>

<li>Caching everything on day one and finding the problems in production.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Default to no caching, then allow specific routes. Opt-in beats opt-out when the failure mode is a data leak.</li>

<li>Own the policy at the origin so it travels with your code and exists in staging.</li>

<li>Use <code>s-maxage</code> for the edge and <code>max-age</code> for browsers. They are different problems.</li>

<li>Always set <code>stale-if-error</code>. It is free outage insurance.</li>

<li>Expose a cache status header and monitor hit ratio per route, not site-wide.</li>

<li>Build purge-on-publish into the application rather than relying on TTLs alone.</li>

<li>Make the leak test part of CI, so a future config change cannot reintroduce it quietly.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Can a CDN cache dynamic pages at all?</h3>



<p class="wp-block-paragraph">Yes, and that is where most of the benefit is. Dynamic does not mean personalised. A blog post rendered from a database is identical for every anonymous visitor and can be cached at the edge for minutes. Only genuinely per-user content must bypass.</p>



<h3 class="wp-block-heading">What is the difference between private and no-store?</h3>



<p class="wp-block-paragraph"><code>private</code> permits browser caching but forbids shared caches. <code>no-store</code> forbids writing the response to any cache at all. Use both for authenticated pages; use <code>no-store</code> alone for genuinely sensitive responses.</p>



<h3 class="wp-block-heading">Why did my hit ratio collapse after adding a cookie banner?</h3>



<p class="wp-block-paragraph">Because the consent tool sets a cookie on every visitor and your CDN treats any cookie as a bypass signal, or you are varying on <code>Cookie</code>. Configure the CDN to ignore all cookies except the specific ones that indicate a session.</p>



<h3 class="wp-block-heading">How do I verify private pages are not being cached?</h3>



<p class="wp-block-paragraph">Request the page with a real session cookie and confirm the cache status shows a bypass or miss, never a hit. Then request the same URL with no cookies and confirm the personalised content is absent. Do this after every cache rule change.</p>



<h3 class="wp-block-heading">Should I cache API responses?</h3>



<p class="wp-block-paragraph">Public read-only endpoints, yes — with short TTLs and a cache key that includes whatever parameters affect the response. Anything requiring an Authorization header should be treated as private by default.</p>



<h3 class="wp-block-heading">Do I still need caching at the origin?</h3>



<p class="wp-block-paragraph">Yes. The CDN protects you from repeat requests for the same URL; it does nothing for the first request, for uncacheable routes, or during a purge. Origin-level caching handles those.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">A CDN in front of a dynamic site is a classification problem wearing a performance costume. Sort your routes into public, private and static; express that at the origin with <code>Cache-Control</code>; make session cookies a hard bypass on both the read and write path; and keep tracking parameters out of the cache key.</p>



<p class="wp-block-paragraph">Then test the way an anonymous visitor arrives, not the way you browse your own site. Two curl commands — one with a session, one without — catch the failure that matters, and they take about ten seconds. Run them every time you change a cache rule and the Monday email never arrives.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Want a CDN set up without the surprises?</h2>



<p class="wp-block-paragraph">I work with teams on edge caching for dynamic sites — route classification, cache headers that survive a redesign, purge-on-publish wired into the application, and the monitoring that tells you when hit ratio quietly drops.</p>



<p class="wp-block-paragraph">If you would rather have this done carefully the first time, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/cdn-setup-dynamic-content/">CDN Setup Without Breaking Dynamic Content</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
