Aditya Sharma

WordPress

A CDN for WordPress: What Actually Helps, and the Layer My Own Site Is Failing

On this page, 7 sections

My homepage takes 1.0 seconds to start arriving. A stylesheet on the same domain takes 0.22 seconds.

Both come from the same Cloudflare edge, both are cached, and the difference between them is the entire subject of this post. Five runs each, measured this morning, 3 September 2026:

The edge serves my CSS in 0.22s and refuses to touch my HTML.
run1 ttfb=1.025484 x-runcloud-cache: HIT cf-cache-status: DYNAMIC
run2 ttfb=0.995375 x-runcloud-cache: HIT cf-cache-status: DYNAMIC
run3 ttfb=0.997329 x-runcloud-cache: HIT cf-cache-status: DYNAMIC
run4 ttfb=1.055049 x-runcloud-cache: HIT cf-cache-status: DYNAMIC
run5 ttfb=1.137921 x-runcloud-cache: HIT cf-cache-status: DYNAMIC

That is five runs of curl -w "%{time_starttransfer}" against my own homepage, three seconds apart, grepping the two cache headers out of the response.

Three runs against a cached stylesheet came back cf-cache-status: HIT with cache-control: max-age=2592000 and a rising age, at 0.226s, 0.235s and 0.219s. x-runcloud-cache: HIT on every HTML request means my origin’s page cache is working perfectly. PHP barely ran. WordPress barely ran.

And it still took a second, because cf-cache-status: DYNAMIC means Cloudflare never looked in its own cache and went straight to my origin server, every single time, for a file whose contents had not changed.

This is the layer my site is failing. I said as much when I measured what actually makes my site slow and concluded the slow part was not WordPress.

Here is the part I did not do then, which is work out exactly why the edge refuses to help, and what would have to be true before I could safely make it.

Why DYNAMIC is the default, in Cloudflare’s own words

Cloudflare docs page: Default cache behavior
developers.cloudflare.com, Default cache behavior, last updated 14 August 2026. Screenshot taken 3 September 2026.

The reason is one sentence in Cloudflare’s default cache behaviour documentation, last updated 14 August 2026:

Cloudflare only caches based on file extension and not by MIME type. The Cloudflare CDN does not cache HTML or JSON by default.

Cloudflare docs, Default cache behavior, retrieved 3 September 2026

The extension list, and what is missing from it

The page lists the extensions that are cached by default, and it is a long list: 7Z, AVI, AVIF, BMP, CSS, DOC, EOT, GIF, GZ, ICO, JPG, JPEG, JS, JSON is absent, MP3, MP4, OTF, PDF, PNG, SVG, TTF, WEBM, WEBP, WOFF, WOFF2, ZIP and about thirty more.

Notice what that list is: assets. Not pages.

So the decision is made on the URL, before the request is even sent to your origin. Cloudflare’s own definition of the status makes the ordering explicit:

DYNAMIC: Cloudflare determined at request time that the asset is not eligible for cache, so the request went to the origin web server without a cache lookup.

Cloudflare docs, Cloudflare cache responses, retrieved 3 September 2026

DYNAMIC is not a miss

Without a cache lookup. That phrase is the whole mechanism. DYNAMIC is not a miss. A miss means the edge looked and found nothing, which is why the next request can be a hit.

DYNAMIC means the edge did not look and will never look, so the thousandth identical request costs exactly what the first one did.

My cache-control: public, max-age=300, stale-while-revalidate=86400 header is being ignored, not because Cloudflare disrespects it, but because the URL never got as far as the code that reads it.

Decomposing the second

With three measurements you can split that 1.0 second into named parts. First, the edge serving a cached static file, which is the floor:

Part one: the edge serving a cached file

Three runs on a cached static file: cf-cache-status: HIT at 0.219s, 0.226s and 0.235s.

Second, the same static file with a random query string appended, which forces Cloudflare to fetch it from my origin. No PHP, no MySQL, no WordPress. Just nginx opening a file:

Part two: the edge fetching from origin

The command was the same loop against a static asset with ?b=$RANDOM$RANDOM appended to defeat the edge cache. Four runs came back cf-cache-status: MISS at 0.510s, 0.588s, 0.513s and 0.525s.

Time to first byte for HTML, a static file miss and a static file hit
Measured with curl against adityaarsharma.com, 2 September 2026.

Third, the HTML, at 1.0 seconds. Subtract, and the picture is unambiguous:

  • 0.22s is my client reaching the Cloudflare edge and being served from its memory. Pure network.
  • +0.29s is the edge reaching my origin and back for a static file. That is the origin round trip, and it is a distance problem, not a software problem.
  • +0.47s is what my origin adds for HTML over a static file, even with the page cache reporting HIT.

The uncomfortable number

The last number is the uncomfortable one and I am not going to paper over it.

A page-cache HIT is not free: nginx still has to match the request against the cache rules, check the cookie exclusions, look up the cached entry and hand back roughly 172 kilobytes rather than 679 bytes.

Nearly half a second of it, though, is more than that accounting explains, and I do not have a clean measurement of where it goes.

I can measure the outside of the box precisely and I am guessing about the inside, so I am saying so.

What the decomposition does establish, without any guessing: if Cloudflare served that HTML from the edge, the 0.29s origin trip and the 0.47s origin work both disappear.

The page would start arriving in roughly 0.22 seconds instead of 1.0.

That is a Largest Contentful Paint improvement of three quarters of a second before a single byte of the page is optimised, which is a bigger number than anything else on my performance list.

The Core Web Vitals thresholds are close enough on this site that this one change would move the grade.

What has to be true before HTML at the edge is safe

This is where most guides stop and just tell you to turn on Cache Everything. Do not, until you can answer all four of these, because a shared HTML cache serves one user’s page to another user by design.

1. Logged-in users must never get a cached page

1. Logged-in users must never receive a cached page. WordPress signals a session with cookies whose names begin wordpress_logged_in_, wp-postpass_ and comment_author_. My origin already handles this correctly.

I checked, by sending the cookie: The check is one request with a fake session cookie: curl -H 'Cookie: wordpress_logged_in_abc=test' against the homepage, grepping for the two cache headers.

x-runcloud-cache: BYPASS, and the response took 1.88 seconds instead of 1.0 because PHP genuinely ran. The origin is doing the right thing.

The question is whether the edge would, and the answer with a naive Cache Everything rule is no, because a cache rule that ignores cookies will happily store the admin bar version of your homepage and serve it to the world.

1.88 seconds to first byte with a WordPress session cookie
The same homepage with a session cookie attached, 2 September 2026.

The correct shape is a bypass rule evaluated before the cache rule, expressed in Cloudflare’s rules language:

# Cache rule 1, priority 1: bypass when a session cookie is present
(http.request.headers.cookie contains "wordpress_logged_in_")
or (http.request.headers.cookie contains "wp-postpass_")
or (starts_with(http.request.uri.path, "/wp-admin"))
-> Cache eligibility: Bypass cache

The full rule also matches comment_author_, woocommerce_items_in_cart and wp_woocommerce_session_ cookies, plus /wp-login.php and any path starting /wp-json.

Cache rule 2, at priority 2, is the Cache Everything rule that only sees what rule 1 lets through.

Order matters and it is the thing people get wrong. Cloudflare evaluates cache rules in order and the first match wins, so the bypass has to be first. Put the cache-everything rule first and the bypass never runs.

2. Carts and checkout, by path and by cookie

2. Carts and checkout must be excluded by path as well as by cookie. A WooCommerce cart cookie is only set once something is in the cart.

A visitor with an empty cart hitting /checkout/ has no cookie yet, so a cookie-only rule caches the empty checkout page, and the next visitor gets it.

Exclude /cart/, /checkout/, /my-account/ and the AJAX endpoints by path regardless of cookies. WooCommerce also uses a fragments call for the cart count precisely because that number cannot be part of a cached page.

3. Nonces, and the bug nobody can reproduce

3. Nonces must not be cached. This is the failure that produces bug reports nobody can reproduce.

A WordPress nonce is generated from the user, the action, a session token and a time tick, and it is valid for a bounded window.

Cache a page containing a form nonce for an hour and every visitor who submits that form after the nonce expires gets a security check failure.

It is intermittent, it depends on when the cache filled rather than when the user arrived, and it looks like nothing when you test it yourself thirty seconds later.

If a page contains a form for logged-out users, either exclude it or make the form fetch its nonce over AJAX at submit time.

The same reasoning is why application passwords and the REST API sit outside anything cacheable.

4. Purge on publish

4. You need a purge on publish. An edge TTL of one hour means an hour of stale content after every edit unless something calls Cloudflare’s purge API.

Most WordPress caching plugins with a Cloudflare integration do this on save_post and transition_post_status. Verify it rather than trusting it: edit a post, then curl the URL and look at the age header.

If age keeps climbing across the edit, nothing purged.

The one that is nearly free, and that almost nobody sets

Before Cache Everything, there is a smaller change with none of the risk.

My static assets carry cache-control: max-age=2592000, thirty days, and the age header on the CSS above was 64, 68 and 71 seconds across three requests three seconds apart.

The edge copy was about a minute old and climbing normally, so that layer is behaving.

One header for browsers, one for the edge

What is not set is a separate edge lifetime.

cache-control is a single instruction to two very different caches: a browser, which is one user’s disk and can be wrong for as long as that user does not clear it, and a shared edge cache, which you can purge from an API in seconds.

Splitting them is what CDN-Cache-Control is for, and Cloudflare’s documentation on BYPASS spells out the precedence: Cloudflare-CDN-Cache-Control beats CDN-Cache-Control, which beats Cache-Control.

In nginx, match the static extensions in a location ~* \.(css|js|woff2|jpg|jpeg|png|webp|avif|svg)$ block and add two headers: Cache-Control "public, max-age=604800" for browsers, seven days, and CDN-Cache-Control "public, max-age=31536000" for the edge, a year.

A week in the browser so a returning visitor gets an update within days, a year at the edge because you can purge it whenever you deploy. This costs nothing, breaks nothing, and needs none of the four conditions above.

What does not work

Turning on Cache Everything and hoping. Without the cookie bypass ordered first, you will eventually serve a logged-in page to an anonymous visitor. That is a data exposure, not a performance regression, and you will find out from a customer.

Development Mode is not a test

Using Development Mode as a test. Development Mode bypasses the edge cache entirely, so testing in it tells you exactly nothing about what the edge will do afterwards. Test with a cache rule scoped to a single URL path instead.

Your plugin dashboard is not evidence

Believing your plugin’s dashboard. Every caching plugin has a screen that says the cache is working.

Mine says so, and it is telling the truth about the layer it owns while the layer above it is doing nothing.

The only reliable test is a header from outside, which is the whole method in the post on which caching layer is actually failing.

Assuming the edge is close to your visitors and far from your origin. Check.

My Cloudflare colo is in Singapore, from the cf-ray suffix on every response, and the origin trip from there costs about 0.29 seconds.

If most of your traffic is in one country and your origin is in that country, edge-caching HTML buys you less than it buys me.

Reading your own access logs will tell you where your traffic is before you optimise for where you assume it is.

Do this in the next ten minutes

Run one command against your own site and read one header:

curl -s -o /dev/null -D - https://YOURSITE.com/ \
  -w "\nttfb=%{time_starttransfer}\n" | grep -iE 'cf-cache-status|age:|cache-control'

If cf-cache-status says DYNAMIC, your HTML is going to your origin on every request and you have a Cache Everything decision to make.

If it says HIT, log in and run it again with your session cookie, and make very sure it says BYPASS. That second check is the one that matters, and it takes fifteen seconds.

Resources

Tell me where I am wrong

Your email is not published and I do not add it to any list. Corrections with a source are the ones I act on fastest.

Keep reading

More in WordPress

Every piece in WordPress

  1. 01 WordPress Capabilities and Roles, Read From the Source Administrator holds 61 capabilities, editor 34, subscriber 2. And edit_post is not stored anywhere, because map_meta_cap computes it per post at check time. Cyber Security 11 min
  2. 02 The WordPress Autoload Set: What Loads on Every Request, and the Guard That Usually Does Not Apply The 150,000 byte autoload guard added in 6.6 sits on a filter that four common call styles never reach, and existing 'yes' rows are… WordPress 9 min
  3. 03 WordPress Plugin Supply Chain: What Actually Happened in the Documented Incidents Five incidents with primary sources and dates. In four of them the code was fine and the delivery channel was compromised. Cyber Security 11 min
  4. 04 Why WordPress Malware Scanners Miss Cloaked Spam, and How to Actually Check A scanner reads files on disk. Cloaked spam is a decision made at request time. Here is the mechanism, and the check that settles… Cyber Security 11 min
  5. 05 Application Passwords in WordPress: The Security Model, and Where It Leaks 24 characters, no scope, no expiry, and an admin key can install plugins over REST. What core actually does, read from the source. Cyber Security 10 min
  6. 06 Reading Your WordPress Access Logs for the Things Scanners Will Not Tell You A scanner reports on files. The access log reports on events. Seven tested queries that separate a real compromise from the daily noise. Cyber Security 10 min