---
title: "A CDN for WordPress: What Actually Helps, and the Layer My Own Site Is Failing"
url: https://adityaarsharma.com/cdn-for-wordpress-what-actually-helps/
date: 2026-09-24
modified: 2026-09-03
lang: en
author: "Aditya Sharma"
description: "My HTML is cf-cache-status DYNAMIC at 1.0s. A CSS file on the same domain is HIT at 0.22s. Here is the mechanism and what makes edge HTML safe."
categories:
  - "WordPress"
image: https://adityaarsharma.com/wp-content/uploads/2026/09/783c0f07-6e7b-42c3-a401-b961cbb6a360_2912x1632-1024x574.webp
word_count: 2085
---

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

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.](https://adityaarsharma.com/wp-content/uploads/2026/09/783c0f07-6e7b-42c3-a401-b961cbb6a360_2912x1632-scaled.png)`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](https://adityaarsharma.com/what-actually-makes-a-wordpress-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.

On this page

- Why DYNAMIC is the default, in Cloudflare's own words- Decomposing the second- What has to be true before HTML at the edge is safe- The one that is nearly free, and that almost nobody sets- What does not work- Do this in the next ten minutes- Resources
## Why DYNAMIC is the default, in Cloudflare's own words
![Cloudflare docs page: Default cache behavior](https://adityaarsharma.com/wp-content/uploads/2026/09/0d2f5731-309c-4310-81f2-50364398fbca_2880x1800-scaled.png)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](https://adityaarsharma.com/wp-content/uploads/2026/09/8f4abafc-c5f5-4c42-8cd7-34bce680f33a_2912x1632-scaled.png)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](https://adityaarsharma.com/core-web-vitals-wordpress-2026/) are close enough on this site that this one change would move the grade.

Newsletter

## Agents in Production

I check the things our industry takes on trust and publish what I actually found, including when it makes my own work look worse. One researched piece a week.

Email address

Get it weekly

Free. One email a week. Unsubscribe in one click, and I do not send anything else.

## 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](https://adityaarsharma.com/wp-content/uploads/2026/09/ccfb16d3-b6e9-464e-b28b-47e4cd68715b_2400x2400.png)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](https://adityaarsharma.com/wordpress-application-passwords-security-model/) 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](https://adityaarsharma.com/wordpress-caching-layers-which-one-is-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](https://adityaarsharma.com/reading-wordpress-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
- [Cloudflare: Default cache behavior](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/), including the full default cached file extension list and the line about HTML. Last updated 14 August 2026.- [Cloudflare: cache responses](https://developers.cloudflare.com/cache/concepts/cache-responses/), the normative definitions of HIT, MISS, BYPASS, DYNAMIC, REVALIDATED and UPDATING. Last updated 21 August 2026.- [Cloudflare: Cache Rules](https://developers.cloudflare.com/cache/how-to/cache-rules/), where the bypass and cache-everything rules above are configured.- [Cloudflare: CDN-Cache-Control](https://developers.cloudflare.com/cache/concepts/cdn-cache-control/), and the precedence order between the three cache control headers.- [WordPress nonces in the developer handbook](https://developer.wordpress.org/apis/security/nonces/), for why a cached page containing a form is a problem.