<?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>Caching | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/caching/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/caching/</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>Caching | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/caching/</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>Zoho Analytics Import vs Live Connect: The Trade-Offs Nobody Mentions</title>
		<link>https://john-nessime.com/blog/technical-guides/zoho-analytics-import-vs-live-connect/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 30 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Data Engineering]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon RDS]]></category>
		<category><![CDATA[Business Intelligence]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Freshness]]></category>
		<category><![CDATA[Data Integration]]></category>
		<category><![CDATA[Data Warehouse]]></category>
		<category><![CDATA[ETL]]></category>
		<category><![CDATA[Incremental Sync]]></category>
		<category><![CDATA[Live Connect]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Query Timeout]]></category>
		<category><![CDATA[Schema Drift]]></category>
		<category><![CDATA[Zoho]]></category>
		<category><![CDATA[Zoho Analytics]]></category>
		<category><![CDATA[Zoho Databridge]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=346</guid>

					<description><![CDATA[<p>Import mode fails quietly and Live Connect fails loudly. A practical breakdown of what each Zoho Analytics connection mode actually does, where each one wins, the four levers that should drive the choice, and a decision procedure you can run before you build the workspace.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/zoho-analytics-import-vs-live-connect/">Zoho Analytics Import vs Live Connect: The Trade-Offs Nobody Mentions</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 arrives on a Monday. Someone in finance opens the revenue dashboard, compares it against the operational system, and asks why one says 41 and the other says 47. Nothing is broken. No sync failed. No alert fired. The dashboard is just showing you a snapshot from six hours ago, and absolutely nothing on the screen tells you that.</p>



<p class="wp-block-paragraph">That is the real cost of picking the wrong data connection mode, and it is why the choice between Zoho Analytics import vs Live Connect deserves more than five minutes during setup. Both modes work. Both are documented. But they fail in completely different ways, and one of those failure modes is silent.</p>



<p class="wp-block-paragraph">This post covers what each mode actually does under the hood, where each one genuinely wins, the four levers that should drive the decision, a short procedure for running that decision yourself, and the arguments people repeat that don&#8217;t hold up once you have a workspace in production.</p>



<h2 class="wp-block-heading">What the two modes actually do</h2>



<p class="wp-block-paragraph">Strip away the marketing language and the difference is where the data lives when someone opens a report.</p>



<p class="wp-block-paragraph"><strong>Data Import</strong> copies rows out of your source and stores them inside Zoho Analytics. Reports query that stored copy. You schedule refreshes to pull new data on an interval, and between refreshes your dashboard is a photograph, not a window.</p>



<p class="wp-block-paragraph"><strong>Live Connect</strong> does not copy anything. Zoho pulls metadata about your tables and columns, then generates SQL against your source database every single time a report or dashboard is opened. Your database does the work. Zoho does the rendering.</p>



<p class="wp-block-paragraph">That one architectural difference cascades into everything else: what you get billed for, which features you can use, who owns schema changes, and what happens when a query gets slow.</p>



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



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



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



<p class="wp-block-paragraph">Import mode is the default for a reason, and the reasons are good ones.</p>



<ul class="wp-block-list">
<li><strong>Report speed is yours to control.</strong> Because the data sits in Zoho&#8217;s storage, load time doesn&#8217;t depend on how busy your production database is at 9am on a Monday.</li>



<li><strong>You get the full feature surface.</strong> Query Tables, custom import queries, data type changes on imported columns, and blending several unrelated sources into one workspace all work in import mode.</li>



<li><strong>Your production database is insulated.</strong> An analyst who builds an accidental many-to-many join hits a copy, not the system taking customer orders.</li>



<li><strong>Incremental fetch keeps sync cheap.</strong> Rather than re-pulling a whole table on every schedule, you nominate a column that identifies new rows and pull only what changed.</li>



<li><strong>It works on every plan.</strong> There is no tier gate on importing data, which matters if you are prototyping before anyone has approved a budget.</li>
</ul>



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



<p class="wp-block-paragraph">The first problem is the one from the opening: <strong>staleness is invisible</strong>. A Zoho Analytics dashboard built on imported data looks exactly the same whether it refreshed two minutes ago or two days ago. There is no built-in &#8220;as of&#8221; banner on the chart. If your refresh schedule is daily and someone is making a call at 4pm, they are working from this morning&#8217;s world.</p>



<p class="wp-block-paragraph">The second is <strong>billing shape</strong>. Zoho Analytics prices on stored rows, counted as the sum of all rows across every table in every workspace in the account. Import mode is therefore the mode that grows your bill. Every table you sync &#8220;just in case&#8221; is a line item. Rates change, so check the current pricing page rather than trusting any figure you read in a blog post, but understand the mechanism: rows stored equals money.</p>



<p class="wp-block-paragraph">Third, <strong>the copy drifts and nobody owns it</strong>. Six months in, a table has a scheduled import that has been silently failing, or someone changed a transformation in the source and the imported copy still reflects the old logic. Import mode creates a second version of the truth, and second versions need maintenance.</p>



<p class="wp-block-paragraph">Fourth, <strong>refresh frequency is plan-gated</strong>. How often you can schedule a sync depends on your subscription tier. If your reporting requirement is &#8220;within the hour&#8221; and your plan permits fewer refreshes per day than that implies, import mode cannot meet the requirement no matter how you configure it.</p>



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



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



<ul class="wp-block-list">
<li><strong>There is exactly one copy of the data.</strong> No reconciliation conversation, because there is nothing to reconcile. What the report shows is what the database holds at that moment.</li>



<li><strong>Sensitive data stays where it already is.</strong> For teams with a data residency position, or a security team that objects to bulk-copying customer records into a BI vendor&#8217;s storage, this is often the deciding argument on its own.</li>



<li><strong>Foreign keys carry over.</strong> Tables linked by foreign keys in the source get lookup relationships created automatically in Zoho, which is a genuine time saver compared to wiring lookup columns by hand.</li>



<li><strong>Row storage isn&#8217;t the cost driver.</strong> You&#8217;re not paying to warehouse a duplicate of a table you already pay to host on Amazon RDS, Snowflake, or a Postgres instance on your own VPS.</li>
</ul>



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



<p class="wp-block-paragraph">Here is the one that catches people, and it is worth reading twice: <strong>Live Connect does not automatically track schema changes</strong>. Add a column, rename a column, drop a table, change a data type in the source, and Zoho will not follow along. Reports built on the affected objects land in a Mismatch state and you have to reconcile them manually through the Sync Design option in the data source settings.</p>



<p class="wp-block-paragraph">This is the inverse of what most people assume. &#8220;Live&#8221; sounds like &#8220;always current in every respect.&#8221; It means live <em>data</em>, not live <em>structure</em>. A routine migration that renames <code>customer_id</code> to <code>account_id</code> will break dashboards that nobody looked at that week, and you&#8217;ll find out from a user rather than from a monitor.</p>



<p class="wp-block-paragraph">The rest of the trade-offs:</p>



<ul class="wp-block-list">
<li><strong>Query timeout.</strong> Zoho documents a default query timeout of 2.5 minutes on Live Connect. Anything slower fails. On a source that hasn&#8217;t been tuned for analytical queries, that ceiling arrives faster than you expect.</li>



<li><strong>No Custom Query at import time.</strong> You can&#8217;t hand Zoho a filtered SELECT to narrow what it sees. The documented workaround is to create a view in the source database and connect to that instead.</li>



<li><strong>No Query Tables.</strong> The SQL-over-your-workspace feature depends on data being stored locally. Zoho notes Query Table support for selected databases is enabled on request, so it is worth asking, but do not plan around it.</li>



<li><strong>No data type changes in Zoho.</strong> If a column is text in the source and should be a date, you fix it in the source or in a view. There is no in-Zoho override.</li>



<li><strong>Lookups only within the connection.</strong> You can define lookup rules between tables in the same live connection, not across to other data sources. Reports from different sources can still be combined on a dashboard, but that is a presentation-layer join, not a data-layer one.</li>



<li><strong>It is gated to higher plans.</strong> Zoho&#8217;s documentation places Live Connect on the Premium and Enterprise tiers. If you&#8217;re on a lower plan, the decision has already been made for you.</li>
</ul>



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



<h2 class="wp-block-heading">The four levers behind Zoho Analytics import vs Live Connect</h2>



<p class="wp-block-paragraph">Feature checklists make this look like a close call. It usually isn&#8217;t, once you work out which of these four you actually care about.</p>



<p class="wp-block-paragraph"><strong>1. Your freshness contract.</strong> Not &#8220;how fresh would be nice.&#8221; What decision gets made from this dashboard, and how wrong can the data be before that decision goes bad? A weekly pipeline review tolerates a nightly sync comfortably. A dispatcher deciding which order to fulfil next does not. Write the tolerance down as a number of minutes before you look at any feature table.</p>



<p class="wp-block-paragraph"><strong>2. Where the cost lands.</strong> Import mode moves cost onto your Zoho row count. Live Connect moves it onto your database, in query load and in whatever you pay for the compute that serves it. Neither is free. A warehouse that bills per query scanned is a different economic proposition under Live Connect than a fixed-price MySQL instance on Contabo or InterServer, where extra query load costs nothing until you outgrow the box.</p>



<p class="wp-block-paragraph"><strong>3. Where transformation happens.</strong> If your model needs joins, unions, derived columns, and cleanup, import mode gives you Query Tables to do that inside Zoho. Live Connect pushes all of it down into the source, which means you need database write access and someone comfortable maintaining views. That&#8217;s a team-capability question as much as a technical one.</p>



<p class="wp-block-paragraph"><strong>4. Who owns schema change.</strong> Under Live Connect, every source migration is potentially a reporting incident. That is fine if the same team owns both, and painful if the application team ships migrations without telling anyone. Import mode is more forgiving here, since Zoho can automatically pick up column additions and deletions on synced tables.</p>



<h2 class="wp-block-heading">A decision procedure you can run in ten minutes</h2>



<ol class="wp-block-list">
<li><strong>Check the plan gate first.</strong> If you&#8217;re not on a tier that includes Live Connect, stop. Import mode, tuned well, is the answer.</li>



<li><strong>Write down the freshness tolerance in minutes.</strong> If the number is larger than your plan&#8217;s shortest available sync interval, import mode satisfies it. If it&#8217;s smaller, Live Connect is the only mode that can meet it.</li>



<li><strong>Time the heaviest query.</strong> Take the ugliest aggregation your dashboard will need and run it against the source with realistic filters. If it takes anywhere near 2.5 minutes, Live Connect will fail under real usage. Tune the source or choose import.</li>



<li><strong>List the transformations you need.</strong> If any of them require a Query Table, joining across unrelated sources, or changing a column&#8217;s data type in Zoho, import mode is doing that work.</li>



<li><strong>Ask who ships schema migrations.</strong> If it isn&#8217;t the people who own the dashboards, budget for Sync Design work every release, or use import mode.</li>



<li><strong>Check the security position last, because it can override everything.</strong> If the answer to &#8220;may customer records be stored in a third-party BI platform&#8221; is no, Live Connect is the only option and you build around its constraints.</li>
</ol>



<p class="wp-block-paragraph">In practice, mixed estates are common and reasonable. Live Connect for the operational workspace where freshness is the point, import mode for the historical and blended reporting where transformation and speed matter more.</p>



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



<p class="wp-block-paragraph"><em>&#8220;Live Connect is real-time, so it&#8217;s more accurate.&#8221;</em> It&#8217;s more current. Accuracy is a modelling problem, not a transport problem. A live connection to a table with duplicate lookup keys produces wrong numbers instantly instead of wrong numbers on a schedule.</p>



<p class="wp-block-paragraph"><em>&#8220;Live Connect is cheaper because you don&#8217;t pay for rows.&#8221;</em> Sometimes. You&#8217;ve moved the cost, not removed it. Every dashboard open is a set of queries against your source. On per-query or per-compute-second billing, an enthusiastic team with auto-refreshing dashboards can generate a bill that makes row storage look like a bargain.</p>



<p class="wp-block-paragraph"><em>&#8220;Just enable the cache and Live Connect gets fast.&#8221;</em> Zoho does offer report caching on Live Connect workspaces, with a refresh interval you set in minutes. It genuinely helps. But note what you&#8217;ve just built: a copy of the data with a staleness window, which is the thing you chose Live Connect to avoid. Caching is a legitimate tuning tool, not a free upgrade.</p>



<p class="wp-block-paragraph"><em>&#8220;We&#8217;ll switch modes later if it doesn&#8217;t work out.&#8221;</em> Switching is not a toggle. Zoho does not permit importing data into an existing Live Connect workspace, and the two modes support different feature sets, so a migration means rebuilding the workspace and its reports. Choose deliberately the first time.</p>



<h2 class="wp-block-heading">If you go Live Connect, tune the source first</h2>



<p class="wp-block-paragraph">Live Connect performance is your database&#8217;s performance. Zoho&#8217;s own guidance is blunt about this, and it matches what you&#8217;d do for any reporting workload.</p>



<p class="wp-block-paragraph">Index the columns that lookups and joins run through. Without them, every dashboard open is a table scan:</p>



<pre class="wp-block-code"><code>-- Index the join key that Zoho will generate queries against
CREATE INDEX idx_orders_customer_id ON orders (customer_id);

-- Confirm the planner actually uses it (PostgreSQL)
EXPLAIN ANALYZE
SELECT customer_id, SUM(total)
FROM orders
WHERE order_date &gt;= CURRENT_DATE - INTERVAL '90 days'
GROUP BY customer_id;</code></pre>



<p class="wp-block-paragraph">Because Custom Query isn&#8217;t available in Live Connect, a view is how you narrow and shape what Zoho sees. Connect to the view rather than the raw table:</p>



<pre class="wp-block-code"><code>CREATE VIEW reporting_orders AS
SELECT
    o.id,
    o.customer_id,
    o.order_date,
    o.total,
    CASE WHEN o.status = 'complete' THEN 1 ELSE 0 END AS is_complete
FROM orders o
WHERE o.order_date &gt;= CURRENT_DATE - INTERVAL '2 years';</code></pre>



<p class="wp-block-paragraph">For calculations that get repeated across many reports, a materialized view precomputes the work so the 2.5 minute ceiling stops being a threat. You trade a refresh schedule for query speed, which is a much smaller staleness window than a full import.</p>



<p class="wp-block-paragraph">The rest of the list is short and worth following:</p>



<ul class="wp-block-list">
<li>Keep formula columns simple, and prefer CASE WHEN over deeply nested IF statements.</li>



<li>Make sure lookup columns hold unique values. Duplicate keys degrade query performance badly and quietly inflate aggregates.</li>



<li>Avoid many-to-many joins and circular join paths in your model.</li>



<li>Give Zoho a database user with SELECT plus metadata read on exactly the tables it needs, and nothing more. Missing metadata permission is a common cause of a table that connects but never loads.</li>



<li>Decide your connectivity path deliberately: allowlisting Zoho&#8217;s published IP ranges for a cloud database, or Zoho Databridge as an agent when the database sits in a private network. SSL and SSH tunneling are both supported on the connection.</li>



<li>Put a change-notification step in your migration checklist. If a release renames or drops a column, someone should run Sync Design before users find the Mismatch.</li>
</ul>



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



<h3 class="wp-block-heading">Can I switch a workspace from import mode to Live Connect?</h3>



<p class="wp-block-paragraph">Not as an in-place setting change. Zoho does not permit importing data into an existing Live Connect workspace, and the feature sets differ enough that reports built one way won&#8217;t simply carry over. Treat a mode change as a rebuild and plan the reporting downtime accordingly.</p>



<h3 class="wp-block-heading">Does Live Connect count against my row limit?</h3>



<p class="wp-block-paragraph">Zoho&#8217;s row-based pricing counts rows stored across your workspace tables, and Live Connect doesn&#8217;t store your source data in Zoho. The practical implication is that Live Connect shifts your constraint from storage to source query capacity. Confirm the exact treatment for your account with Zoho before you build a cost model on it, since add-ons and plan definitions change.</p>



<h3 class="wp-block-heading">Why do my Live Connect reports suddenly show a view-cannot-be-accessed error?</h3>



<p class="wp-block-paragraph">Almost always because a table or column the report depends on was renamed or dropped in the source. Live Connect doesn&#8217;t track structural changes automatically. Open the data source settings, check the Mismatch tab, and use Sync Design to remap. Insufficient SELECT or metadata permissions on the source produce a similar symptom.</p>



<h3 class="wp-block-heading">What is incremental fetch, and does it apply to both modes?</h3>



<p class="wp-block-paragraph">Incremental fetch is an import-mode feature. Instead of re-pulling every row on each schedule, you nominate a column that identifies new records, such as an auto-incrementing ID or a timestamp, and choose whether new rows are appended or matched and updated. It cuts sync time dramatically on large transactional tables. It has no meaning under Live Connect, where nothing is fetched on a schedule in the first place.</p>



<h3 class="wp-block-heading">Can I combine Live Connect tables with data from other sources?</h3>



<p class="wp-block-paragraph">Not at the data layer. Lookup rules only work between tables within the same live connection. You can place reports from a Live Connect workspace and reports from other sources side by side on a dashboard, but you cannot join them into a single dataset the way you can with imported tables in one workspace.</p>



<h3 class="wp-block-heading">Is Live Connect available for on-premise and private databases?</h3>



<p class="wp-block-paragraph">Yes, through Zoho Databridge, the agent that bridges a database in a private network to Zoho&#8217;s servers. You choose the connection type during setup, so the same agent supports either mode. If your database is publicly reachable instead, you allowlist Zoho&#8217;s published IP ranges and connect directly.</p>



<h3 class="wp-block-heading">Which mode is better for a Zoho CRM or Zoho Desk workspace?</h3>



<p class="wp-block-paragraph">Neither, in the sense that the question doesn&#8217;t apply. Zoho&#8217;s own application connectors sync data into Analytics on a schedule, which behaves like import mode. Live Connect is for external databases and data warehouses. If you need faster refreshes on Zoho app data, the lever is the connector&#8217;s sync schedule and your plan tier, not the connection mode.</p>



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



<p class="wp-block-paragraph">The Zoho Analytics import vs Live Connect decision is not a performance question. It&#8217;s a question about which failure you&#8217;d rather have.</p>



<p class="wp-block-paragraph">Import mode fails quietly. Numbers stay confidently wrong until someone reconciles them by hand, and the bill grows with every table you sync out of habit. Live Connect fails loudly. Someone renames a column and a dashboard breaks in front of a user, or a heavy query hits the timeout during the Monday review.</p>



<p class="wp-block-paragraph">Loud failures are usually easier to live with, because you find out about them. But they only work if you have the source database, the access to tune it, and a change process that includes reporting. If you don&#8217;t have those three, import mode with an honest refresh schedule and a documented staleness window will serve you far better than a live connection you can&#8217;t maintain.</p>



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



<h2 class="wp-block-heading">Need a second opinion on your Zoho Analytics setup?</h2>



<p class="wp-block-paragraph">Most of the Zoho Analytics work I get called into is somebody discovering the mode choice was made in five minutes eighteen months ago. Things I can help with:</p>



<ul class="wp-block-list">
<li>Auditing an existing workspace and telling you honestly whether the connection mode still fits what you&#8217;re using it for</li>



<li>Profiling and tuning the source database behind a Live Connect workspace so dashboards stop hitting the query timeout</li>



<li>Designing reporting views and materialized views that push transformation into the database where Live Connect can use it</li>



<li>Setting up Zoho Databridge for databases in private networks, including SSL and SSH tunneling and the firewall rules around it</li>



<li>Configuring incremental fetch on import-mode tables to cut sync times and stop row counts drifting upward</li>



<li>Building a schema-change process so source migrations don&#8217;t turn into reporting incidents</li>
</ul>



<p class="wp-block-paragraph">If you want a concrete starting point, send me the slow query, the Mismatch screenshot, or the EXPLAIN output from the aggregation that&#8217;s timing out. That&#8217;s usually enough to tell where the actual problem is.</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/technical-guides/zoho-analytics-import-vs-live-connect/">Zoho Analytics Import vs Live Connect: The Trade-Offs Nobody Mentions</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>The OOM Killer Took MySQL: Redis Object Caching for WordPress</title>
		<link>https://john-nessime.com/blog/technical-guides/redis-object-cache-wordpress/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 04 Aug 2026 03:39:00 +0000</pubDate>
				<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[Redis]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[Website Speed]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=114</guid>

					<description><![CDATA[<p>MySQL was gone in the morning because the kernel picked it to reclaim memory from Redis. Redis ships configured as a durable datastore, and you are using it as a cache. Setup takes ten minutes; the gotchas are memory limits, shared namespaces and the one enormous key nobody mentions.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/redis-object-cache-wordpress/">The OOM Killer Took MySQL: Redis Object Caching for WordPress</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 site went down overnight. MySQL was not running. The logs showed the kernel&#8217;s OOM killer had chosen it, which is what the kernel does when it needs memory and something has to go.</p>



<p class="wp-block-paragraph">The thing that needed the memory was Redis, installed three weeks earlier to speed up the admin, running with the configuration it shipped with. Redis defaults to no memory limit and a policy of never evicting anything. Left alone it will happily grow until the machine runs out, and then the kernel picks a victim, and it will not necessarily pick Redis.</p>



<p class="wp-block-paragraph">That is the framing for everything below. <strong>Redis object caching for WordPress</strong> is genuinely one of the highest-value changes you can make to a busy site, and Redis out of the box is configured as a durable datastore, not a cache. You are storing data you can regenerate at any time. The defaults assume the opposite.</p>



<p class="wp-block-paragraph">Setup takes ten minutes. The gotchas are the post.</p>



<h2 class="wp-block-heading">What it does, and what it doesn&#8217;t</h2>



<p class="wp-block-paragraph">WordPress has an object cache built in, and by default it lasts exactly one request. Every page load fetches the same options, terms, post meta and user meta from MySQL, uses them, and throws them away. A persistent object cache keeps that data between requests, so the second visitor does not repeat the first visitor&#8217;s queries.</p>



<p class="wp-block-paragraph">Two expectations worth setting before you install anything.</p>



<p class="wp-block-paragraph"><strong>It is not a page cache.</strong> Page caching serves a finished HTML file and never runs PHP. Object caching runs the whole application and makes the database part faster. They live in different drop-ins, do different jobs, and coexist happily.</p>



<p class="wp-block-paragraph"><strong>Its biggest effect is where page caching does not reach.</strong> Logged-in requests, wp-admin, WooCommerce carts and checkouts, REST API calls. If your front end is already cached and fast while the dashboard crawls, an object cache is aimed exactly at your problem.</p>



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



<ol class="wp-block-list">
<li><strong>Install Redis</strong> from your distribution&#8217;s packages, and bind it to localhost unless something remote genuinely needs it.</li>
<li><strong>Install a PHP Redis client.</strong> PhpRedis (the PECL extension) is faster than the pure-PHP Predis and is what you want on a real server. Relay is a newer option worth knowing about if you are pushing volume.</li>
<li><strong>Install the Redis Object Cache plugin</strong>, then add the constants below to <code>wp-config.php</code> <em>before</em> enabling the drop-in.</li>
<li><strong>Enable it</strong>, which copies <code>object-cache.php</code> into <code>wp-content/</code>. That file, not the plugin, is what WordPress actually loads.</li>
<li><strong>Verify it end to end</strong>, which is step five for a reason.</li>
</ol>



<pre class="wp-block-code"><code>// wp-config.php, above the "That's all, stop editing" line.

define( 'WP_REDIS_HOST',     '127.0.0.1' );
define( 'WP_REDIS_PORT',     6379 );

// Two different protections, and you want both. The database
// number scopes flushes; the prefix prevents key collisions.
define( 'WP_REDIS_DATABASE', 1 );
define( 'WP_REDIS_PREFIX',   'acme-prod:' );

// Ceiling for keys written with no expiry, so nothing lives forever.
define( 'WP_REDIS_MAXTTL',   86400 );</code></pre>



<p class="wp-block-paragraph">One note if you are following an older tutorial: the prefix constant used to be <code>WP_CACHE_KEY_SALT</code> and was renamed to <code>WP_REDIS_PREFIX</code> in version 2.0 of the plugin, to stop it being confused with a core constant of the same name. The old name is still read for compatibility, but if a guide is still using it, that guide predates a major version and its other advice may too.</p>



<p class="wp-block-paragraph">Then prove it works rather than trusting a green indicator on a settings page:</p>



<pre class="wp-block-code"><code># The plugin's own view.
wp redis status

# Write through WordPress, read it back through WordPress.
wp eval 'wp_cache_set("probe","ok","test",60); echo wp_cache_get("probe","test");'

# Then confirm the key landed in the database and prefix you expect.
redis-cli -n 1 --scan --pattern 'acme-prod:*' | head</code></pre>



<p class="wp-block-paragraph">Site Health under Tools will also tell you whether WordPress considers a persistent object cache to be in use. That is the check to trust, because it reflects what core sees rather than what the plugin believes.</p>



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



<h2 class="wp-block-heading">Gotcha 1: the defaults are a datastore&#8217;s defaults</h2>



<p class="wp-block-paragraph">The one from the opening paragraph, and the only one on this list that can take a server down.</p>



<pre class="wp-block-code"><code># /etc/redis/redis.conf

# Without a limit, Redis grows until the machine runs out and the
# kernel kills something. It will not necessarily kill Redis.
maxmemory 512mb

# The default policy is to refuse writes rather than evict. Correct
# for a datastore, wrong for a cache: you want the least recently
# used keys dropped, silently, forever.
maxmemory-policy allkeys-lru

# Cache contents are regenerable by definition. Persisting them buys
# nothing and costs you fork latency on save and slower restarts.
save ""
appendonly no</code></pre>



<p class="wp-block-paragraph">Size <code>maxmemory</code> against what the machine can spare after MySQL and PHP-FPM have what they need, not against what is currently free. Free memory on a healthy Linux box is mostly page cache, and it is doing useful work.</p>



<p class="wp-block-paragraph">The honest trade-off with <code>allkeys-lru</code>: under memory pressure Redis will evict things you would rather it kept, and cache misses go up. That is the correct failure mode. The alternative is write errors surfacing inside WordPress, or an outage.</p>



<p class="wp-block-paragraph">If you are on managed hosting, do not change any of this. Ask them what they have set, because they may have tuned it and may not appreciate you undoing it.</p>



<h2 class="wp-block-heading">Gotcha 2: one Redis, several sites</h2>



<p class="wp-block-paragraph">Redis is a single key-value store. Two WordPress installs pointed at it with default settings both write <code>post_123</code> to the same key and read each other&#8217;s data. On a server hosting several sites that is a correctness bug. Where it gets genuinely nasty is staging.</p>



<p class="wp-block-paragraph">Staging is usually a clone of production, which means it inherits production&#8217;s <code>wp-config.php</code>, which means it inherits production&#8217;s Redis settings. Now both environments share a namespace, and a developer clearing the cache on staging clears production&#8217;s, or worse, staging serves production&#8217;s cached objects.</p>



<p class="wp-block-paragraph">The two constants guard against different things and you want both:</p>



<ul class="wp-block-list">
<li><strong><code>WP_REDIS_DATABASE</code></strong> selects a logical database, of which Redis provides sixteen by default. This scopes flushes, so one site clearing its cache does not clear another&#8217;s.</li>
<li><strong><code>WP_REDIS_PREFIX</code></strong> namespaces every key. This prevents collisions if two installs ever land in the same database, which is what happens the day somebody copies a config file.</li>
</ul>



<p class="wp-block-paragraph">Make the prefix readable and environment-specific: <code>acme-prod:</code> and <code>acme-staging:</code>, not a random string. It is a namespace, not a secret, and you will want to read it in <code>redis-cli</code> at some point.</p>



<p class="wp-block-paragraph">If you use Relay as the client, a dedicated database <em>and</em> a unique prefix per install is a requirement rather than a suggestion.</p>



<h2 class="wp-block-heading">Gotcha 3: some things are never cached, and one thing is enormous</h2>



<p class="wp-block-paragraph">Not every cache group is persistent. WordPress marks some as non-persistent, and plugins can mark their own, so those groups keep behaving exactly as they did before you installed anything. If you expected a specific thing to get faster and it did not, this is a likely reason and it is not a misconfiguration.</p>



<p class="wp-block-paragraph">The more interesting one is <code>alloptions</code>. WordPress stores all autoloaded options as a <em>single</em> cache entry. On a site with a bloated options table, that is one very large key, fetched on every request, and invalidated whenever any autoloaded option is written. Under concurrency that produces a lot of simultaneous re-priming of the same large value.</p>



<p class="wp-block-paragraph">Which means an object cache does not excuse a bloated options table; it changes where the cost lands. Check what your keys actually look like:</p>



<pre class="wp-block-code"><code># Reports the largest key per type. If one key dwarfs everything
# else, it is probably alloptions and your options table needs work.
redis-cli -n 1 --bigkeys</code></pre>



<h2 class="wp-block-heading">Gotcha 4: flushing is not free</h2>



<p class="wp-block-paragraph">Clearing the object cache on a busy site means every subsequent request rebuilds what it needs from MySQL simultaneously. On a quiet site nobody notices. On a busy one you get a load spike at exactly the moment you were trying to fix something.</p>



<p class="wp-block-paragraph">Know what your flush actually does. Some implementations issue a Redis-wide flush; others delete only keys matching your prefix. Those are very different operations when the instance is shared, and finding out during an incident is a bad time.</p>



<p class="wp-block-paragraph">Also worth deciding deliberately: whether deploys flush the cache. Stale cached objects after a plugin update cause genuinely confusing bugs, so a flush in the deploy script is often right. Just do it knowing it costs a load spike, and put it at the start of a quiet window rather than at five o&#8217;clock on a Friday.</p>



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



<p class="wp-block-paragraph"><strong>&#8220;Drop-in: invalid&#8221; or &#8220;not connected&#8221;.</strong> Usually the PHP extension is missing, or PHP cannot reach Redis on the host and port you gave it. Check with <code>php -m | grep redis</code> and <code>redis-cli ping</code> from the same machine PHP runs on, which is not always the machine you are logged into.</p>



<p class="wp-block-paragraph"><strong>Everything looks connected but nothing is cached.</strong> Something replaced <code>object-cache.php</code>. Optimisation plugins install their own drop-in, and only one can win. Check the file&#8217;s contents, not just that it exists.</p>



<p class="wp-block-paragraph"><strong>The site got slower.</strong> Latency to Redis. A remote instance across a network adds a round trip to every cache operation, and there are a lot of them per request. Localhost or a Unix socket beats a fast network every time.</p>



<p class="wp-block-paragraph"><strong>Stale data after an update.</strong> Flush once, then work out which plugin is caching something it should be invalidating. Do not put a flush on every request to make the symptom go away.</p>



<p class="wp-block-paragraph"><strong>Memory keeps climbing.</strong> Either no <code>maxmemory</code>, or keys written with no expiry and no <code>WP_REDIS_MAXTTL</code> ceiling. Check <code>INFO memory</code> and the eviction counters.</p>



<p class="wp-block-paragraph"><strong>Staging and production interfering.</strong> They are sharing a database number, a prefix, or both. Fix the config on staging as part of your restore script so it cannot recur.</p>



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



<ul class="wp-block-list">
<li>Running Redis with default memory settings on a shared box.</li>
<li>Leaving persistence enabled for a pure cache workload.</li>
<li>No prefix and no database number, so sites read each other&#8217;s objects.</li>
<li>Cloning production to staging without changing the Redis settings.</li>
<li>Trusting the plugin&#8217;s status page instead of Site Health and an actual read-back.</li>
<li>Expecting it to speed up an already page-cached front end.</li>
<li>Installing it and ignoring an options table full of autoloaded junk.</li>
<li>Predis on a production site when PhpRedis is available.</li>
<li>Putting Redis on a different host and adding a network hop to every cache call.</li>
<li>Flushing the cache during peak traffic to fix a display bug.</li>
<li>Two plugins competing for the <code>object-cache.php</code> drop-in.</li>
</ul>



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



<ul class="wp-block-list">
<li>Set <code>maxmemory</code> and <code>allkeys-lru</code> before you point any site at it.</li>
<li>Disable persistence unless Redis is also doing a job that needs it.</li>
<li>Unique database number and readable prefix per install and per environment.</li>
<li>Bind to localhost, or require a password and TLS if it must be remote.</li>
<li>PhpRedis over Predis, and Redis on the same host as PHP where you can.</li>
<li>A <code>WP_REDIS_MAXTTL</code> ceiling so nothing lives forever by accident.</li>
<li>Verify with Site Health plus a write-and-read test, not a status indicator.</li>
<li>Clean up autoloaded options rather than caching the mess.</li>
<li>Monitor memory used, evicted keys and hit rate; a collapsing hit rate is an early warning.</li>
<li>Make staging&#8217;s Redis config part of the restore script, not something to remember.</li>
</ul>



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



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



<h3 class="wp-block-heading">Do I need Redis if I already have a caching plugin?</h3>



<p class="wp-block-paragraph">Probably, because they solve different problems. Page caching handles anonymous visitors; object caching handles everything that cannot be page cached, which is the admin, logged-in users and anything transactional. Run both.</p>



<h3 class="wp-block-heading">Redis or Memcached?</h3>



<p class="wp-block-paragraph">For WordPress, Redis, mostly because the tooling and plugin ecosystem around it is better and you get richer diagnostics. Memcached is perfectly capable and slightly simpler. This is not a decision worth agonising over.</p>



<h3 class="wp-block-heading">How much memory should I give it?</h3>



<p class="wp-block-paragraph">Start modestly, watch memory used and evicted keys for a week, and raise it if evictions are constant while free memory remains. Sizing from an article is guessing; sizing from your own eviction counter is not.</p>



<h3 class="wp-block-heading">Is it safe to lose everything in Redis?</h3>



<p class="wp-block-paragraph">Yes, for an object cache. Everything in it is regenerable from MySQL, which is exactly why persistence is unnecessary. Losing it costs a burst of database load, not data.</p>



<h3 class="wp-block-heading">Why did my dashboard not get faster?</h3>



<p class="wp-block-paragraph">Check the cache is genuinely active first, then look at what else is slow: outbound HTTP calls during admin requests, a large autoloaded options blob, or the heartbeat. An object cache removes repeated database work and nothing else.</p>



<h3 class="wp-block-heading">Should staging share the production Redis?</h3>



<p class="wp-block-paragraph">It can, with a different database number and a different prefix. It is cleaner not to. Either way, set it as part of the restore process so a clone never inherits production&#8217;s cache namespace.</p>



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



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Redis ships configured to protect data it assumes you cannot afford to lose. You are storing data you could regenerate in a second. Every default that follows from that assumption, unlimited memory, no eviction, persistence to disk, is wrong for your use case and one of them can take the server down.</p>



<p class="wp-block-paragraph">So set <code>maxmemory</code> and an eviction policy before you enable the drop-in, give every install its own database and prefix, and verify with Site Health rather than a green dot. Then it is genuinely one of the best changes you can make to a WordPress site.</p>



<h2 class="wp-block-heading">Want it set up properly?</h2>



<p class="wp-block-paragraph">Object caching is quick to install and easy to get subtly wrong in ways that show up weeks later. Work I take on:</p>



<ul class="wp-block-list">
<li>Installing and tuning Redis object caching on a VPS or dedicated server, including memory limits, eviction and persistence settings.</li>
<li>Auditing an existing setup for shared namespaces, competing drop-ins and caches that are not actually active.</li>
<li>Multi-site and multi-environment configuration so staging can never touch production&#8217;s cache.</li>
<li>Cleaning up autoloaded options so the object cache is caching something sensible.</li>
<li>Monitoring for Redis memory, evictions and hit rate, wired into Prometheus and Grafana.</li>
<li>Diagnosing sites where caching was installed and nothing got faster.</li>
</ul>



<p class="wp-block-paragraph">Send me your Site Health info and <code>redis-cli INFO memory</code>, and I will tell you what is worth changing.</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/technical-guides/redis-object-cache-wordpress/">The OOM Killer Took MySQL: Redis Object Caching for WordPress</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</title>
		<link>https://john-nessime.com/blog/technical-guides/core-web-vitals-wordpress/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 03 Aug 2026 12:33:00 +0000</pubDate>
				<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[CDN]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[CLS]]></category>
		<category><![CDATA[Core Web Vitals]]></category>
		<category><![CDATA[INP]]></category>
		<category><![CDATA[JavaScript]]></category>
		<category><![CDATA[LCP]]></category>
		<category><![CDATA[PageSpeed Insights]]></category>
		<category><![CDATA[SEO]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[Website Speed]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=95</guid>

					<description><![CDATA[<p>You got PageSpeed Insights to 98 and six weeks later Search Console still says the same URLs are failing. The score and the assessment measure different things, and Lighthouse cannot measure INP at all. Here is how to fix Core Web Vitals on WordPress in the order that actually moves the report.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/core-web-vitals-wordpress/">A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You spent a fortnight on it. Deferred the scripts, converted everything to WebP, turned on a caching plugin, tuned the CDN. PageSpeed Insights now returns 98 on desktop and 92 on mobile, and you screenshot it for the client.</p>



<p class="wp-block-paragraph">Six weeks later the Core Web Vitals report in Search Console still says the same URLs are failing. Nothing moved.</p>



<p class="wp-block-paragraph">Here is why, and it is the single most important thing to understand before touching anything: <strong>the score and the assessment are two different measurements of two different things.</strong> The score is Lighthouse running a simulated page load on throttled hardware, once, from wherever you are. The assessment is Chrome telemetry from real visitors on real devices, aggregated over 28 days, graded at the 75th percentile.</p>



<p class="wp-block-paragraph">Worse: Lighthouse cannot measure one of the three metrics at all. Interaction to Next Paint requires an actual human interacting with the page, so no lab tool produces it. You can score 100 and fail INP for every mobile visitor you have.</p>



<p class="wp-block-paragraph">This walks through fixing <strong>Core Web Vitals on WordPress</strong> the way that actually shifts the report: get field data first, then work each metric in the order that pays.</p>



<h2 class="wp-block-heading">Field data first, or you are guessing</h2>



<p class="wp-block-paragraph">Three facts that determine everything else.</p>



<p class="wp-block-paragraph"><strong>You are graded at the 75th percentile.</strong> Three quarters of page views need a good result. Not the median, not your laptop. The fourth-slowest visitor out of four, typically a mid-range Android phone on patchy mobile data, decides whether you pass.</p>



<p class="wp-block-paragraph"><strong>The window is 28 days and it rolls.</strong> Deploy a fix today and the report will not reflect it for weeks, and even then it moves gradually as old data ages out. Anyone promising you a same-week improvement in Search Console does not understand the mechanism.</p>



<p class="wp-block-paragraph"><strong>Thin URLs fall back to origin-level data.</strong> If a page has too few visits for its own dataset, Google uses a group or your whole origin instead. So one badly performing template can drag URLs that are individually fine.</p>



<p class="wp-block-paragraph">Practically: start in the Core Web Vitals report in Search Console to see which templates fail and how many URLs are affected. Then run PageSpeed Insights on a representative URL and read <em>only the field data section at the top</em> to decide what is broken. Use the Lighthouse section below it to work out why, which is what lab data is genuinely good for.</p>



<p class="wp-block-paragraph">If you want faster feedback than 28 days, add real user monitoring. Google&#8217;s <code>web-vitals</code> JavaScript library reports all three metrics from your own visitors and you can send them wherever you like. On a site where performance matters commercially, this is worth the afternoon it takes.</p>



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



<h2 class="wp-block-heading">LCP: the biggest thing on screen</h2>



<p class="wp-block-paragraph">Largest Contentful Paint is when the largest visible element finishes rendering. Good is under 2.5 seconds. On WordPress it is almost always the hero image or the first image in post content, and the causes are boringly consistent.</p>



<h3 class="wp-block-heading">The lazy-loaded hero</h3>



<p class="wp-block-paragraph">Still the most common LCP problem on WordPress sites, years after core started handling it. Core skips lazy loading on the first image in content and adds <code>fetchpriority="high"</code> to the likely LCP candidate, but the heuristic only works on images core knows about. A hero rendered by a page builder, a theme header, or a slider often sits outside that, and some optimisation plugins re-add <code>loading="lazy"</code> to everything regardless.</p>



<pre class="wp-block-code"><code># Look at what the server actually sends. If your hero image has
# loading="lazy" on it, you have found your LCP problem.
curl -s https://example.com/ | grep -o '&lt;img[^&gt;]*&gt;' | head -5</code></pre>



<p class="wp-block-paragraph">For a theme-rendered hero, be explicit rather than hoping a heuristic catches it:</p>



<pre class="wp-block-code"><code>&lt;!-- No loading="lazy". width and height reserve space, which
     also protects CLS. fetchpriority tells the browser this
     is the one image worth fetching before everything else. --&gt;
&lt;img src="/hero.webp"
     width="1600" height="900"
     fetchpriority="high"
     decoding="async"
     alt=""&gt;</code></pre>



<h3 class="wp-block-heading">Slow server response</h3>



<p class="wp-block-paragraph">LCP cannot be faster than time to first byte. On shared hosting with no page cache, an uncached WordPress response can eat most of your budget before a single byte of image is requested. Full-page caching is the single highest-yield change on most WordPress sites, and a CDN in front of it removes the geographic distance as well.</p>



<p class="wp-block-paragraph">If TTFB is still poor with caching on, the cache is being bypassed. Cookies from analytics, a cart, or a logged-in session will do it. Check the cache-status header on a real request rather than trusting the plugin&#8217;s dashboard.</p>



<h3 class="wp-block-heading">Render-blocking CSS</h3>



<p class="wp-block-paragraph">The browser will not paint until it has the stylesheets. A theme loading four separate CSS files plus a font stylesheet plus an icon library delays every LCP candidate on the page. Self-host fonts with <code>font-display: swap</code>, drop icon libraries you use six glyphs from, and be sceptical of &#8220;combine CSS&#8221; plugin features, which on HTTP/2 often make things worse rather than better.</p>



<h3 class="wp-block-heading">Speculative loading, which is free</h3>



<p class="wp-block-paragraph">Worth knowing because most WordPress performance guides predate it. Since WordPress 6.8, core ships speculative loading using the Speculation Rules API: the browser starts fetching a link&#8217;s destination when the visitor interacts with it, so the next page is partly or entirely loaded before they arrive. It is on by default for logged-out visitors, using prefetch with &#8220;conservative&#8221; eagerness, and it is a progressive enhancement with no downside for browsers that do not support it.</p>



<p class="wp-block-paragraph">You can push it further, at a cost:</p>



<pre class="wp-block-code"><code>add_filter( 'wp_speculation_rules_configuration', function ( $config ) {
    // Prerender actually renders the next page, including running
    // its JavaScript. Much faster, and analytics or anything with
    // side effects may fire for a visit that never happens.
    return array(
        'mode'      =&gt; 'prerender',
        'eagerness' =&gt; 'moderate',
    );
} );</code></pre>



<p class="wp-block-paragraph">Test that carefully on anything transactional. Core also honours <code>no-prefetch</code> and <code>no-prerender</code> CSS classes on individual links, which is the simplest way to keep carts, checkouts and logout links out of it.</p>



<h2 class="wp-block-heading">INP: the hard one, and it is your JavaScript</h2>



<p class="wp-block-paragraph">Interaction to Next Paint measures the time from a user interacting to the browser painting the response, across every interaction on the page, and reports roughly the worst one. Good is under 200 milliseconds; above 500 is poor.</p>



<p class="wp-block-paragraph">It replaced First Input Delay in March 2024, and the change matters more than a metric swap sounds. FID measured only the delay before processing the first interaction. INP measures the whole round trip, every time. Plenty of sites that comfortably passed FID fail INP, because the thing that was always sluggish is now the thing being graded.</p>



<p class="wp-block-paragraph">Since no lab tool reports it, use Total Blocking Time in Lighthouse as a proxy, and Chrome DevTools&#8217; Performance panel to find which handler is blocking. On WordPress the usual suspects:</p>



<ul class="wp-block-list">
<li><strong>Plugin JavaScript loading site-wide.</strong> A form builder, a slider and a popup tool each shipping their bundle on every page, including pages that use none of them.</li>
<li><strong>Third-party tags.</strong> Tag managers, chat widgets, heatmaps, ad scripts. These run on the main thread and are usually the largest single contributor.</li>
<li><strong>Long tasks in event handlers.</strong> Anything doing real work synchronously in response to a click.</li>
<li><strong>jQuery-era code</strong> attaching handlers to hundreds of elements on a listing page.</li>
</ul>



<p class="wp-block-paragraph">The fixes, in order of yield. First, stop shipping JavaScript to pages that do not use it. Conditionally dequeue plugin assets rather than accepting that a contact form script belongs on every article. This alone often solves INP.</p>



<p class="wp-block-paragraph">Second, audit the third-party tags. Every one is somebody&#8217;s request from two years ago, and at least one of them is for a tool nobody uses any more. Removing a tag is the cheapest performance work available.</p>



<p class="wp-block-paragraph">Third, break up long tasks so the browser can paint between chunks:</p>



<pre class="wp-block-code"><code>// Yielding lets the browser respond to the click that started this,
// instead of finishing all the work first. INP measures exactly
// that gap, so yielding moves the metric even though total work
// stays the same.
async function processAll(items) {
  for (const item of items) {
    doWork(item);
    await new Promise(resolve =&gt; setTimeout(resolve, 0));
  }
}</code></pre>



<p class="wp-block-paragraph">Newer Chrome versions offer <code>scheduler.yield()</code>, which does the same thing without losing your place in the task queue. Feature-detect it and fall back to the above.</p>



<p class="wp-block-paragraph">One honest warning: &#8220;delay JavaScript until user interaction&#8221; features in optimisation plugins look like an INP fix and frequently are not. Delaying scripts until the first interaction means the first interaction now waits for all of them to load and execute, which is the worst possible moment. It improves lab scores and can make INP worse.</p>



<h2 class="wp-block-heading">CLS: usually an afternoon</h2>



<p class="wp-block-paragraph">Cumulative Layout Shift measures unexpected movement of visible content. Good is under 0.1, and it is the metric most likely to be fixable in one sitting.</p>



<ul class="wp-block-list">
<li><strong>Images without dimensions.</strong> Set <code>width</code> and <code>height</code> so the browser reserves space before the file arrives. WordPress does this for media library images; hand-written theme markup often does not.</li>
<li><strong>Ads and embeds.</strong> Reserve a fixed-height container. An ad slot that collapses to nothing and then expands is a large shift on every page view.</li>
<li><strong>Fonts.</strong> A web font swapping in at a different metric reflows everything. <code>font-display: swap</code> plus <code>size-adjust</code> on the fallback, or a system font stack, both work.</li>
<li><strong>Cookie banners and notices injected by JavaScript</strong> that push the page down rather than overlaying it.</li>
<li><strong>Lazy-loaded content above the fold</strong> that appears after paint.</li>
</ul>



<p class="wp-block-paragraph">Chrome DevTools&#8217; Performance panel highlights layout shifts with the element that moved, which turns this from detective work into a checklist.</p>



<h2 class="wp-block-heading">The order I&#8217;d work in</h2>



<ol class="wp-block-list">
<li><strong>Anything in the &#8220;poor&#8221; band</strong>, whichever metric it is. Poor hurts more than two mediocre metrics.</li>
<li><strong>Caching and TTFB</strong>, because it is one change and it moves LCP for every page at once.</li>
<li><strong>The LCP image</strong> on your highest-traffic template.</li>
<li><strong>JavaScript you can delete.</strong> Unused plugin assets and dead third-party tags, for INP.</li>
<li><strong>CLS</strong>, which is quick and visible to users even where the number already passes.</li>
<li><strong>Everything else</strong>, measured one change at a time.</li>
</ol>



<p class="wp-block-paragraph">What I would skip: chasing a green Lighthouse score once field data passes, stacking a second optimisation plugin on top of the first, and micro-optimising a metric that is already comfortably good. There is no bonus for 1.1 seconds when 2.5 is the bar.</p>



<h2 class="wp-block-heading">When the numbers don&#8217;t move</h2>



<p class="wp-block-paragraph"><strong>It has been two weeks.</strong> That is normal. The window is 28 days and rolling, so give it a full month before concluding anything.</p>



<p class="wp-block-paragraph"><strong>Lab improved, field did not.</strong> You optimised something the lab measures and users do not experience, or the problem is INP, which the lab never measured. Go back to the field data section.</p>



<p class="wp-block-paragraph"><strong>Desktop passes, mobile fails.</strong> Expected, and mobile is what is graded for most sites. Test with CPU throttling on, because the gap is usually processing rather than network.</p>



<p class="wp-block-paragraph"><strong>One URL is fine but the group fails.</strong> Origin-level fallback, or a different template in the same group is dragging it. Check what else Search Console has bundled together.</p>



<p class="wp-block-paragraph"><strong>It regressed after a plugin update.</strong> Very common, and the reason to record your field numbers before and after any significant change rather than relying on memory.</p>



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



<ul class="wp-block-list">
<li>Treating the PageSpeed Insights score as the thing being measured.</li>
<li>Optimising INP with a lab tool that cannot measure INP.</li>
<li>Testing from a fast laptop on fibre and concluding the site is fine.</li>
<li>Lazy-loading the hero image, or letting a plugin re-add it.</li>
<li>Running two optimisation plugins that both minify and both defer.</li>
<li>Enabling &#8220;delay JavaScript until interaction&#8221; and expecting INP to improve.</li>
<li>Judging a fix after a week.</li>
<li>Loading every plugin&#8217;s JavaScript on every page.</li>
<li>Adding a CDN while the origin has no page cache.</li>
<li>Fixing the homepage when the failing template is the blog archive.</li>
</ul>



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



<ul class="wp-block-list">
<li>Decide what is broken from field data; work out why from lab data.</li>
<li>Fix by template, not by URL, since one template covers thousands of pages.</li>
<li>Full-page caching first, then a CDN, then image work.</li>
<li>Explicit <code>width</code> and <code>height</code> on every image in theme markup.</li>
<li><code>fetchpriority="high"</code> on the LCP image and lazy loading on nothing above the fold.</li>
<li>Dequeue plugin assets on pages that do not use them.</li>
<li>Review third-party tags on a schedule and delete the ones nobody owns.</li>
<li>Self-host fonts with <code>font-display: swap</code>.</li>
<li>Real user monitoring so you are not waiting a month for feedback.</li>
<li>Record field numbers before and after every significant change.</li>
</ul>



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



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



<h3 class="wp-block-heading">Why is my PageSpeed score 95 but Search Console says I&#8217;m failing?</h3>



<p class="wp-block-paragraph">They measure different things. The score is a simulated load on your connection; the assessment is real Chrome users at the 75th percentile over 28 days. Read the field data section at the top of PageSpeed Insights, not the score.</p>



<h3 class="wp-block-heading">How long before a fix shows up?</h3>



<p class="wp-block-paragraph">The data window is 28 days and rolls daily, so improvement appears gradually and takes about a month to fully reflect. Real user monitoring gives you same-day feedback if you cannot wait.</p>



<h3 class="wp-block-heading">Will a caching plugin fix Core Web Vitals?</h3>



<p class="wp-block-paragraph">It will help LCP substantially, because TTFB is a floor under it. It does very little for INP, which is about JavaScript execution, and nothing for CLS. Caching is a strong first move and not a complete answer.</p>



<h3 class="wp-block-heading">What happened to FID?</h3>



<p class="wp-block-paragraph">INP replaced it in March 2024 and FID is retired. Any guide still listing it predates the change, which is a useful signal about the rest of its advice.</p>



<h3 class="wp-block-heading">Do I need to switch themes?</h3>



<p class="wp-block-paragraph">Sometimes, and it is a bigger project than it looks. A heavy multipurpose theme loading a page builder, a slider library and an icon set on every request is fighting you. Measure first, because a theme swap on a site with hundreds of pages is rarely the cheapest route to passing.</p>



<h3 class="wp-block-heading">Does passing actually help rankings?</h3>



<p class="wp-block-paragraph">Core Web Vitals are a ranking signal among many, and content relevance dominates. The reliable win is on the user side: faster pages convert better and get abandoned less. Treat ranking as a bonus rather than the business case.</p>



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



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">You cannot fix what you are not measuring, and the tool most people measure with cannot see the metric most people are failing. Lighthouse is a debugger. The Chrome User Experience Report is the exam.</p>



<p class="wp-block-paragraph">So open Search Console before you open PageSpeed Insights, find out which of the three is actually red, and fix that one. Then wait a month before deciding whether it worked, because the measurement takes longer than the work.</p>



<h2 class="wp-block-heading">Site failing and not sure why?</h2>



<p class="wp-block-paragraph">Most of the WordPress sites I get sent have already had a fortnight thrown at the wrong metric. Work I take on:</p>



<ul class="wp-block-list">
<li>Diagnosing which Core Web Vital is actually failing, for which templates, from field data rather than a score.</li>
<li>LCP work: caching and TTFB, image priority and formats, render-blocking CSS, CDN configuration.</li>
<li>INP work, which is mostly JavaScript archaeology: dequeuing plugin assets by page, auditing third-party tags, breaking up long tasks.</li>
<li>CLS fixes across templates, including ads, embeds, fonts and consent banners.</li>
<li>Setting up real user monitoring so you get feedback in a day instead of a month.</li>
<li>Untangling sites running two or three overlapping optimisation plugins.</li>
</ul>



<p class="wp-block-paragraph">Send me your Search Console Core Web Vitals report and a URL from a failing template, and I will tell you what is actually wrong.</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/technical-guides/core-web-vitals-wordpress/">A 100 Score and a Failing Report: Fixing Core Web Vitals on a Real WordPress Site</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Your Site Is Fast and Your Dashboard Is Not: Fixing a Slow WordPress Admin</title>
		<link>https://john-nessime.com/blog/wordpress/slow-wordpress-admin/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:34 +0000</pubDate>
				<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Performance]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Autoloaded Options]]></category>
		<category><![CDATA[Caching]]></category>
		<category><![CDATA[Database Optimization]]></category>
		<category><![CDATA[Heartbeat API]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Object Cache]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[Query Monitor]]></category>
		<category><![CDATA[Website Performance]]></category>
		<category><![CDATA[WordPress Debugging]]></category>
		<category><![CDATA[WordPress Hosting]]></category>
		<category><![CDATA[wp-admin]]></category>
		<category><![CDATA[WP-CLI]]></category>
		<category><![CDATA[WP-Cron]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=58</guid>

					<description><![CDATA[<p>Your homepage loads instantly and your dashboard takes eight seconds. That is not a contradiction: wp-admin is the only part of your site that never gets cached. Here is where the time actually goes, in the order worth checking.</p>
<p>The post <a href="https://john-nessime.com/blog/wordpress/slow-wordpress-admin/">Your Site Is Fast and Your Dashboard Is Not: Fixing a Slow WordPress Admin</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 ticket says the site is fine but the dashboard takes eight seconds. You check the homepage: loads instantly, green across the board in every speed test you throw at it. You log in, click Posts, and watch the spinner. Same server, same database, same plugins. One is fast and one is not.</p>



<p class="wp-block-paragraph">Here is the thing that explains almost every case of this, and it is worth getting straight before you touch a single setting: <strong>wp-admin is the only part of your site that never gets cached.</strong> Every page cache plugin, every CDN, every reverse proxy in front of WordPress explicitly excludes logged-in requests. It has to, otherwise you would serve one editor&#8217;s draft screen to another. So the front end you keep measuring is a static HTML file being handed over by Cloudflare or Nginx, and the dashboard is your actual application running end to end, every single time.</p>



<p class="wp-block-paragraph">A <strong>slow WordPress admin</strong> is not a separate problem from a slow site. It is the same problem, visible. The cache was hiding it, not fixing it.</p>



<p class="wp-block-paragraph">This post walks through where that time actually goes, in the order I check things: how to tell server time from browser time, the <code>wp_options</code> table, outbound HTTP requests that block your page load, admin-ajax and the heartbeat, wp-cron, database bloat, and the server underneath it. Plus the troubleshooting steps for the cases that do not fit the pattern.</p>



<h2 class="wp-block-heading">First, split the problem in two</h2>



<p class="wp-block-paragraph">Do not start changing things. Find out whether you are waiting on the server or on the browser, because the fixes have nothing in common.</p>



<p class="wp-block-paragraph">Open the browser dev tools, go to the Network tab, hard reload an admin page, and look at the document request. If TTFB on that first request is three seconds, the server is thinking and nothing you do to JavaScript will help. If TTFB is 200ms and the page still feels slow, you have a rendering problem: too many scripts, a bloated admin notice area, a media grid loading hundreds of full-size images.</p>



<p class="wp-block-paragraph">For server-side time, install <a href="https://wordpress.org/plugins/query-monitor/" target="_blank" rel="noreferrer noopener">Query Monitor</a>. It is the single most useful diagnostic tool in the WordPress ecosystem and it costs nothing. It gives you, per admin page load: total queries and their time, which queries came from which plugin, every HTTP request WordPress made server-side and how long each took, hook timings, and PHP errors. Nine times out of ten the answer is sitting in the HTTP API panel or the Queries panel, and you will have it in under a minute.</p>



<p class="wp-block-paragraph">One caution: Query Monitor itself adds overhead, so use it to find the culprit, then deactivate it before you measure the improvement.</p>



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



<h2 class="wp-block-heading">The invisible one: your autoloaded options</h2>



<p class="wp-block-paragraph">This is the failure that hides for years and then bites everybody at once, and it is where I look first on any slow WordPress admin.</p>



<p class="wp-block-paragraph">WordPress loads every option marked as autoload in one query at the start of <em>every single request</em>, then unserializes the lot into memory. That design is fine when the total is a few dozen kilobytes. It stops being fine when a page builder decides to autoload its full asset manifest, a slider plugin stores every slide&#8217;s settings in one row, an analytics plugin caches an API response there, and three plugins you uninstalled two years ago left their rows behind.</p>



<p class="wp-block-paragraph">Now every admin click pulls several megabytes out of MySQL and runs it through PHP&#8217;s unserializer before WordPress has drawn anything. No error, no warning, no slow query in the log worth noticing. Just a tax on every request.</p>



<h3 class="wp-block-heading">Measuring it</h3>



<p class="wp-block-paragraph">There is a trap in the usual SQL query you will find online. WordPress 6.6 changed the <code>autoload</code> column from a simple yes/no to a set of values: <code>on</code>, <code>off</code>, <code>auto</code>, <code>auto-on</code>, <code>auto-off</code>. Older rows keep <code>yes</code> and <code>no</code>, and there is no upgrade routine, so a real site has a mix. Querying only for <code>autoload = 'yes'</code> undercounts, sometimes badly.</p>



<pre class="wp-block-code"><code>-- Total bytes loaded on every request.
-- Adjust wp_ if your table prefix is different.
SELECT SUM(LENGTH(option_value)) AS autoload_bytes
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on');

-- The worst offenders, largest first.
SELECT option_name, LENGTH(option_value) AS size_bytes
FROM wp_options
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on')
ORDER BY size_bytes DESC
LIMIT 25;</code></pre>



<p class="wp-block-paragraph">If you have WP-CLI, this is quicker and you do not need database credentials:</p>



<pre class="wp-block-code"><code># Total size of everything autoloaded, in bytes.
wp option list --autoload=on --format=total_bytes

# Biggest autoloaded options. Note the sort happens in the shell:
# wp option list only sorts by option_id, option_name or option_value.
wp option list --autoload=on --fields=option_name,size_bytes | sort -n -k 2 | tail -25</code></pre>



<p class="wp-block-paragraph">Site Health flags this too, under Tools then Site Health, once the total crosses core&#8217;s threshold. Treat that warning as an audit trigger rather than a number to chase.</p>



<h3 class="wp-block-heading">Fixing it without breaking anything</h3>



<p class="wp-block-paragraph">Read the list before you delete anything. You are looking for three categories:</p>



<ul class="wp-block-list">
<li><strong>Orphans.</strong> Options with the prefix of a plugin that is no longer installed. Safe to delete after a backup.</li>
<li><strong>Wrongly autoloaded.</strong> Settings for a plugin that only ever runs on one admin screen. These should not be autoloaded, but they should not be deleted either.</li>
<li><strong>Cache data in the wrong place.</strong> A plugin storing an API response or an asset map as a permanent option. Usually safe to delete, because the plugin will rebuild it, but check that assumption on staging first.</li>
</ul>



<p class="wp-block-paragraph">For the middle category, flip autoload off instead of deleting. Use the WordPress API rather than raw SQL, because a direct <code>UPDATE</code> leaves the object cache holding the old value and you will spend twenty minutes convinced nothing changed:</p>



<pre class="wp-block-code"><code># Stop autoloading a specific option. Run once, then remove.
wp eval "wp_set_option_autoload( 'some_plugin_bulky_setting', false );"</code></pre>



<p class="wp-block-paragraph">While you are in there, clear out expired transients. They live in the same table and orphaned ones accumulate quietly, especially on sites that have been through a few plugin migrations:</p>



<pre class="wp-block-code"><code>wp transient delete --expired</code></pre>



<h2 class="wp-block-heading">Outbound HTTP requests that block your page load</h2>



<p class="wp-block-paragraph">This one is regional and it catches people out badly. WordPress makes server-side HTTP calls during admin requests: core update checks, plugin and theme update checks, the Events and News dashboard widget, and whatever your commercial plugins do to phone home for licence validation.</p>



<p class="wp-block-paragraph">Those calls are synchronous. If the endpoint is slow to reach from your server, or a firewall is silently dropping the packets rather than refusing them, PHP sits there until the timeout expires. Two plugins doing that with a five-second timeout each is ten seconds of dashboard, and absolutely nothing in the interface tells you why.</p>



<p class="wp-block-paragraph">Query Monitor&#8217;s HTTP API panel shows every one of these with its duration, which is usually enough. To confirm from the server directly:</p>



<pre class="wp-block-code"><code># How long does the server take to reach the WordPress.org API?
# -w prints the timing, -o discards the body, -s hides the progress meter.
curl -w "%{time_total}n" -o /dev/null -s https://api.wordpress.org/core/version-check/1.7/</code></pre>



<p class="wp-block-paragraph">If that takes seconds, or hangs, you have found it. For a pure diagnostic, you can temporarily block all outbound HTTP from WordPress and see whether the dashboard snaps back:</p>



<pre class="wp-block-code"><code>// wp-config.php, ABOVE the "stop editing" line.
// DIAGNOSTIC ONLY. This breaks updates, licence checks and
// anything else that calls out. Remove it once you have your answer.
define( 'WP_HTTP_BLOCK_EXTERNAL', true );

// Optionally allow specific hosts back through while testing.
define( 'WP_ACCESSIBLE_HOSTS', 'api.wordpress.org,*.wordpress.org' );</code></pre>



<p class="wp-block-paragraph">Do not leave that in place as a fix. If outbound connectivity is genuinely the problem, the real solution is at the network layer: fix the firewall rule, fix DNS resolution on the box, or move to a host that does not throttle outbound connections. If you are on shared hosting and cannot influence any of that, a small VPS from somewhere like InterServer will usually behave better than a crowded shared box, purely because you control the network stack.</p>



<p class="wp-block-paragraph">The dashboard news widget is the one piece here you can just remove, and most teams never look at it:</p>



<pre class="wp-block-code"><code>// In a site-specific plugin, not in a parent theme.
add_action( 'wp_dashboard_setup', function () {
    remove_meta_box( 'dashboard_primary', 'dashboard', 'side' );
} );</code></pre>



<h2 class="wp-block-heading">admin-ajax.php and the heartbeat</h2>



<p class="wp-block-paragraph">Open the Network tab in dev tools, filter on <code>admin-ajax.php</code>, and leave a dashboard tab sitting idle for two minutes. You will see requests firing on a timer. That is the Heartbeat API, and it powers autosave, post locking, and session expiry warnings. Those are real features and you should not rip them out.</p>



<p class="wp-block-paragraph">The cost is that each tick boots WordPress, loads every active plugin, runs any handlers hooked into it, and occupies a PHP worker for the duration. The post editor ticks fastest because autosave needs to feel responsive; other admin screens tick more slowly. Multiply by every open tab and every logged-in editor, and on a small server you can spend a meaningful share of your PHP workers on polling.</p>



<p class="wp-block-paragraph">The proportionate fix is to slow it down rather than kill it:</p>



<pre class="wp-block-code"><code>// Slow the heartbeat down across admin screens.
// Core clamps this to its allowed range, so an out-of-range
// value is quietly ignored rather than applied.
add_filter( 'heartbeat_settings', function ( $settings ) {
    $settings['interval'] = 60;
    return $settings;
} );</code></pre>



<p class="wp-block-paragraph">Worth being honest about the trade-off: at a longer interval, autosave and post-lock detection get less immediate. On a single-author blog that is invisible. On a newsroom where three people edit the same article, post locking becoming less prompt is a genuine cost and you should leave the editor alone and throttle elsewhere.</p>



<p class="wp-block-paragraph">Heartbeat is not the only thing hitting that endpoint. Cart fragments, live search, popup builders, notification pollers and bots all target <code>admin-ajax.php</code>. Look at the <code>action</code> parameter in the request payload to see which plugin is responsible, then deal with that plugin rather than blaming the endpoint.</p>



<h2 class="wp-block-heading">wp-cron riding along on your admin requests</h2>



<p class="wp-block-paragraph">WordPress has no scheduler of its own. It checks for due tasks on page loads and fires them off, which means somebody&#8217;s page view pays for your backup job, your feed import and your email queue. On a low-traffic site the person unlucky enough to be that page view is usually you, logged in, clicking around the admin.</p>



<pre class="wp-block-code"><code># What is scheduled, and is anything badly overdue?
wp cron event list

# Run everything due right now, so you can time it in isolation.
wp cron event run --due-now</code></pre>



<p class="wp-block-paragraph">A long list of overdue events means cron is not firing reliably, which is its own problem. The fix is to take the scheduler off page loads and give it to the operating system:</p>



<pre class="wp-block-code"><code>// wp-config.php
define( 'DISABLE_WP_CRON', true );</code></pre>



<pre class="wp-block-code"><code># crontab -e, as the user that owns the WordPress files.
# Every five minutes is enough for most sites.
*/5 * * * * cd /var/www/example.com &amp;&amp; wp cron event run --due-now &gt;/dev/null 2&gt;&amp;1</code></pre>



<p class="wp-block-paragraph">The order matters. Set the constant and the system cron in the same change, because if you disable wp-cron and forget the cron job, scheduled posts stop publishing and nobody notices for a week.</p>



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



<h2 class="wp-block-heading">Database bloat, and the list tables that trip over it</h2>



<p class="wp-block-paragraph">Admin list screens do work the front end never does. Counting posts by status for those &#8220;All | Published | Draft&#8221; links, joining postmeta for sortable columns, building filter dropdowns from distinct values. All of it scales with table size, and none of it is cached.</p>



<pre class="wp-block-code"><code># Which tables are actually large?
wp db size --tables --human-readable</code></pre>



<p class="wp-block-paragraph">What usually turns up:</p>



<ul class="wp-block-list">
<li><strong><code>wp_postmeta</code> far larger than <code>wp_posts</code>.</strong> Normal to a point. Suspicious when the ratio is extreme, which usually means a plugin writing meta per page view.</li>
<li><strong>Revisions.</strong> Every save keeps a full copy of the post. Cap them with <code>define( 'WP_POST_REVISIONS', 10 );</code> in wp-config, which limits new ones without touching existing content.</li>
<li><strong>Action Scheduler tables</strong> on WooCommerce sites. Completed actions accumulate. There is a cleanup screen under WooCommerce tools, and it is worth checking before you assume the store itself is slow.</li>
<li><strong>Spam and trashed comments</strong> still counted by every dashboard widget that shows a comment count.</li>
</ul>



<p class="wp-block-paragraph">Two quick wins that need no database work at all. Use Screen Options at the top right of any list table to drop the per-page count from twenty to ten on a slow screen. And switch the Media Library from grid view to list view, because the grid loads far more per scroll.</p>



<h2 class="wp-block-heading">The server underneath</h2>



<p class="wp-block-paragraph">If the application is clean and the admin is still slow, look down a layer. Site Health covers most of it, under Tools then Site Health then Info.</p>



<ul class="wp-block-list">
<li><strong>No persistent object cache.</strong> Without one, every options lookup and every transient read goes to MySQL on every request. Adding Redis or Memcached with the matching drop-in is often the single biggest improvement available to an admin-heavy site, because the admin is exactly the workload that benefits.</li>
<li><strong>OPcache disabled.</strong> PHP recompiling every WordPress file on every request. Free to enable, immediately noticeable.</li>
<li><strong>An old PHP version.</strong> Upgrading a WordPress site&#8217;s PHP is usually the cheapest performance work available, and it is a security matter regardless.</li>
<li><strong>PHP-FPM worker exhaustion.</strong> If requests are queuing for a worker, every page waits, and the admin waits longest because it holds a worker for the whole request. Check the pool status and the error log before adding more workers, because a worker shortage is often a symptom of slow requests rather than the cause.</li>
<li><strong>A low memory limit.</strong> The admin needs more than the front end, especially plugin and update screens.</li>
</ul>



<p class="wp-block-paragraph">If you run your own server, track admin response time the same way you track front-end response time. Scraping PHP-FPM and MySQL metrics into Prometheus and graphing them in Grafana costs an afternoon and turns &#8220;the dashboard feels slower lately&#8221; into something you can actually see.</p>



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



<h3 class="wp-block-heading">Only one admin screen is slow</h3>



<p class="wp-block-paragraph">That points at a specific plugin, not the platform. Query Monitor on that exact screen will name the caller. Common culprits are the plugins list (update checks for every installed plugin) and any settings page that fetches remote data to populate a dropdown.</p>



<h3 class="wp-block-heading">Slow only for some users</h3>



<p class="wp-block-paragraph">Check user meta. Some plugins store per-user data such as dismissed notices, column preferences or activity logs, and one user&#8217;s row can grow enormous. Also check whether the slow users are administrators, since admins load update checks and notices that editors do not.</p>



<h3 class="wp-block-heading">Fast for a while after clearing cache, then slow again</h3>



<p class="wp-block-paragraph">Something is rebuilding a large transient or option on a schedule and it is expensive. Watch <code>wp_options</code> row sizes over a day, or check the cron list for a job that lines up with the slowdown.</p>



<h3 class="wp-block-heading">Slow in one browser or on one machine only</h3>



<p class="wp-block-paragraph">Not a server problem. Test in a private window with extensions disabled. Ad blockers and password managers interact badly with some admin screens, and a stale service worker from a caching plugin can serve half-broken admin assets.</p>



<h3 class="wp-block-heading">Everything looks fine but it is still slow</h3>



<p class="wp-block-paragraph">Bisect on staging, never production. Deactivate all plugins, confirm the admin is fast, then reactivate in halves rather than one at a time. Switch to a default theme too, because <code>functions.php</code> in a commercial theme can do as much work as a plugin.</p>



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



<p class="wp-block-paragraph">Things I would push back on if you suggested them:</p>



<ul class="wp-block-list">
<li>Judging admin performance by front-end speed test scores. Those measure a cached HTML file.</li>
<li>Installing a second caching plugin because the first one &#8220;did not fix the dashboard&#8221;. No page cache will ever cache wp-admin.</li>
<li>Running an <code>UPDATE</code> against the <code>autoload</code> column directly, then wondering why nothing changed. The object cache still holds the old values.</li>
<li>Only counting <code>autoload = 'yes'</code> and concluding the table is fine.</li>
<li>Disabling the heartbeat completely, then losing work when two people edit the same post.</li>
<li>Setting <code>DISABLE_WP_CRON</code> without adding a real cron job.</li>
<li>Deleting rows from <code>wp_options</code> without a backup, or without checking on staging first.</li>
<li>Leaving <code>WP_HTTP_BLOCK_EXTERNAL</code> in place because it made the dashboard fast. It also stopped your security updates.</li>
<li>Adding PHP-FPM workers to hide slow requests instead of finding out why they are slow.</li>
<li>Deactivating plugins one at a time on a live site during business hours.</li>
</ul>



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



<ul class="wp-block-list">
<li>Measure with Query Monitor before changing anything, and measure again with it deactivated.</li>
<li>Check autoloaded option size whenever you install, remove or update a major plugin.</li>
<li>Remove uninstalled plugins&#8217; leftover rows as part of routine maintenance, not as an emergency.</li>
<li>Run a persistent object cache on any site where people spend real time in the admin.</li>
<li>Move wp-cron to a system cron job on every site you manage.</li>
<li>Cap post revisions rather than deleting them in bulk later.</li>
<li>Keep a staging copy so bisecting plugins never happens on production.</li>
<li>Treat Site Health warnings as a starting point for investigation, not a checklist to silence.</li>
<li>Uninstall plugins properly instead of deactivating them and leaving them installed, since update checks still run for installed plugins.</li>
<li>Watch admin response time over time, so you catch a slow regression before somebody files a ticket.</li>
</ul>



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



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



<h3 class="wp-block-heading">Why is my WordPress dashboard slow when the site itself is fast?</h3>



<p class="wp-block-paragraph">Because the front end is being served from a cache and the dashboard cannot be. Page caches and CDNs bypass logged-in requests by design, so wp-admin runs the full PHP and MySQL stack on every click. The dashboard is showing you your site&#8217;s real uncached performance.</p>



<h3 class="wp-block-heading">Will a caching plugin speed up wp-admin?</h3>



<p class="wp-block-paragraph">Not the page caching part, no. What can help is the object caching side, if your caching plugin ships one, because that caches option and transient lookups rather than whole pages. Redis or Memcached with the matching drop-in does the same job more reliably.</p>



<h3 class="wp-block-heading">How much autoloaded data is too much?</h3>



<p class="wp-block-paragraph">Core&#8217;s Site Health check will tell you when you have crossed its threshold, and that is the number to work against rather than one from an article. More usefully: if a single option is measured in hundreds of kilobytes, look at it regardless of the total, because one bad row is easier to fix than a general diet.</p>



<h3 class="wp-block-heading">Is it safe to delete rows from wp_options?</h3>



<p class="wp-block-paragraph">With a backup and a staging test, yes for orphaned rows from plugins you have removed. For anything belonging to an active plugin, turn autoload off instead of deleting, and use <code>wp_set_option_autoload()</code> rather than raw SQL so the object cache stays consistent.</p>



<h3 class="wp-block-heading">Should I disable the Heartbeat API?</h3>



<p class="wp-block-paragraph">Throttle it, do not disable it. Turning it off entirely breaks autosave and post locking, which is a bad trade for a site with more than one editor. Raising the interval keeps both working and removes most of the load.</p>



<h3 class="wp-block-heading">Does the number of plugins matter?</h3>



<p class="wp-block-paragraph">Less than people assume. Twenty well-written plugins can cost less than one that runs a remote API call on <code>admin_init</code>. Count what each plugin does per request, not how many are in the list. Query Monitor attributes queries and HTTP calls to specific plugins, which turns this from a guess into a measurement.</p>



<h3 class="wp-block-heading">Will better hosting fix a slow WordPress admin?</h3>



<p class="wp-block-paragraph">It helps when the constraint is genuinely the server: no OPcache, no object cache, throttled CPU, a shared MySQL instance under load. It does nothing for four megabytes of autoloaded options or a plugin blocking on a remote API. Diagnose first, because moving a slow site to a faster server usually gives you a slightly less slow site and a new bill.</p>



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



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Your dashboard is not a separate, badly built part of WordPress. It is the only place you ever see your site running without a cache in front of it, which makes it the most honest performance signal you have. A slow WordPress admin is telling you something true about the application, and the front end has been politely covering for it.</p>



<p class="wp-block-paragraph">So measure before you change anything, and go in this order: autoloaded options, outbound HTTP requests, admin-ajax traffic, cron, database size, then the server. That sequence puts the highest-yield, lowest-risk checks first, and most of the time you will not get past the second one.</p>



<h2 class="wp-block-heading">Need someone to find where the time is going?</h2>



<p class="wp-block-paragraph">Diagnosing a slow admin is mostly measurement discipline, and it goes faster with someone who has looked at a lot of these. Work I take on regularly:</p>



<ul class="wp-block-list">
<li>Profiling a slow WordPress admin and reporting back with the actual cause, named, rather than a list of generic tips.</li>
<li>Cleaning up <code>wp_options</code> and autoloaded data safely, tested on staging before it touches production.</li>
<li>Setting up Redis or Memcached object caching, OPcache and PHP-FPM tuning on a VPS or dedicated server.</li>
<li>Moving wp-cron to system cron and fixing scheduled jobs that have quietly stopped running.</li>
<li>Database maintenance: revisions, orphaned meta, Action Scheduler cleanup on WooCommerce sites, and indexing where it genuinely helps.</li>
<li>Adding proper monitoring so admin performance regressions show up on a graph instead of in a complaint.</li>
</ul>



<p class="wp-block-paragraph">Send me a Site Health info report and a Query Monitor screenshot from your slowest admin screen, and I will tell you what I would 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/wordpress/slow-wordpress-admin/">Your Site Is Fast and Your Dashboard Is Not: Fixing a Slow WordPress Admin</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>
