Speed Up WordPress with Browser Cache Headers and StackPath CDN

by Fahim

A fresh WordPress install spits out sloppy HTTP headers. Browsers and CDNs end up querying your origin on almost every single asset request. You get 800ms page loads, access logs clogged with 304 Not Modified checks, and your origin CPU spikes even when you haven’t published anything new in weeks.

Here’s how I fix origin cache headers and wire up StackPath CDN edge rules so CSS, JS, and WebP media sit inside visitor browser caches for up to a year, while dynamic pages stay clean and real-time.

Enterprise rackmount server fiber optic connections and network activity lights representing CDN edge caching
Enterprise rackmount server fiber optic connections and network activity lights representing CDN edge caching

The Bottleneck: Why Default Headers Hurt Edge Caching

Spin up WordPress on an unconfigured Apache or Nginx host and check your headers. Half the time, media in /wp-content/uploads/ ships without explicit Cache-Control or Expires response headers. Worse, PHP templates frequently burp out Cache-Control: no-cache, must-revalidate, max-age=0 across static files, or append version strings like ?ver=6.4.2 to stylesheets.

When StackPath grabs an asset from your origin, it checks those headers first. If your origin doesn’t declare a clean max-age or s-maxage, StackPath either guesses with heuristic caching or passes every single request straight through to your server. On a 20MB baseline page with 45 assets, my origin was taking 45 raw hits per visitor. Once I fixed the origin headers and set up edge rules, that dropped to 1 hit for the document HTML. Total load time plunged from 1,240ms down to 215ms.

If PageSpeed Insights is nagging you to serve static assets with an efficient cache policy, this missing header setup is usually why.

Configuring Browser Cache Headers at the Origin

Remember: a CDN is just a reverse proxy. It won’t cache what your origin explicitly tells it not to cache, and it won’t guess lifetimes accurately. You need your web server spitting out strict caching headers on every static file before you flip DNS over to StackPath.

For Apache and LiteSpeed (.htaccess)

If you’re running Apache or LiteSpeed, open the .htaccess file in your WordPress document root. Put this block right at the top, above the standard # BEGIN WordPress block, so Apache handles static assets before rewriting requests through index.php.

This snippet uses mod_expires and mod_headers to set explicit lifetimes:

 ExpiresActive On ExpiresDefault "access plus 1 month" # Media: images, video, audio ExpiresByType image/webp "access plus 1 year" ExpiresByType image/jpeg "access plus 1 year" ExpiresByType image/png "access plus 1 year" ExpiresByType image/svg+xml "access plus 1 year" ExpiresByType image/x-icon "access plus 1 year" # Webfonts ExpiresByType font/ttf "access plus 1 year" ExpiresByType font/woff "access plus 1 year" ExpiresByType font/woff2 "access plus 1 year" # CSS, JavaScript ExpiresByType text/css "access plus 1 year" ExpiresByType application/javascript "access plus 1 year" ExpiresByType text/javascript "access plus 1 year"
   Header set Cache-Control "max-age=31536000, public, no-transform" Header unset ETag 

FileETag None

This tells browsers and edge proxies to stash these assets for 31,536,000 seconds (one year). Stripping the ETag kills pointless revalidation round-trips on static files that already have fixed URLs. If you need more granular MIME rules, check the official Apache mod_expires documentation.

For Nginx (nginx.conf or vhost)

On Nginx, open your server block config (usually sitting in /etc/nginx/sites-available/). Add a dedicated location block matching your static extensions.

Drop this inside your server { ... } block:

location ~* .(jpg|jpeg|gif|png|webp|svg|css|js|ico|woff|woff2|ttf|eot)$ { expires 365d; add_header Cache-Control "public, no-transform"; access_log off; log_not_found off; tcp_nodelay off; open_file_cache max=3000 inactive=120s; open_file_cache_valid 45s; open_file_cache_min_uses 2; open_file_cache_errors off;
}

Test the syntax and reload Nginx:

sudo nginx -t && sudo systemctl reload nginx

Auditing Origin Headers with cURL

Don’t hook up your CDN yet. Make sure your origin is actually serving these headers first, or you’ll waste hours troubleshooting CDN edge rules when the problem is sitting on your own server.

Fire off a quick curl request against a real image or stylesheet on your origin:

curl -I https://example.com/wp-content/themes/twentytwentyfour/style.css

Look through the response headers. You should see something like this:

HTTP/2 200 server: nginx
date: Mon, 18 Mar 2024 14:12:02 GMT
content-type: text/css
content-length: 12480
last-modified: Thu, 08 Feb 2024 09:18:22 GMT
expires: Tue, 18 Mar 2025 14:12:02 GMT
cache-control: max-age=31536000, public, no-transform

Check that cache-control says public with a large max-age, and that expires is set roughly a year out. If you want a refresher on what each directive tells the client, read through the MDN documentation on Cache-Control.

Connecting WordPress to StackPath CDN

Now that the origin is behaving, get StackPath between your users and your host. You can run StackPath as a full reverse proxy on your apex domain, or create a dedicated asset subdomain like cdn.example.com.

  1. Log into StackPath and click Create Site.
  2. Pick CDN Service and enter your WordPress domain.
  3. Set the Origin IP or Hostname to your server’s direct IP address (or origin hostname).
  4. Under SSL, select EdgeSSL to generate a free Let’s Encrypt cert, or upload your own. Make sure the origin SSL port matches your host (port 443 for HTTPS).
  5. If your site gets stuck in a loop right here, you probably need to fix WordPress ERR_TOO_MANY_REDIRECTS behind StackPath CDN by matching origin ports and SSL termination settings.

Once configured, point your DNS CNAME (or root A records if proxying the whole site) to the StackPath delivery address shown in your dashboard.

Configuring Edge Rules for WordPress Static Assets

StackPath inherits origin headers by default. But WordPress plugins love tacking version query strings (like ?ver=1.2.0) onto URLs. That fragments your cache into dozens of identical copies for the exact same file. We’ll fix that with edge rules.

Head to Sites > your site > EdgeRules > Add EdgeRule.

Rule 1: Cache Static Folders

  • Rule Name: Cache WP Uploads and Assets
  • If: Request URL path matches wildcard */wp-content/* OR */wp-includes/*
  • Then: Set Cache TTL to Custom (1 Year / 31536000 seconds)
  • Action: Set Browser Cache TTL to Custom (1 Year / 31536000 seconds)

This guarantees that even if a misconfigured plugin strips origin headers off an image or bundled script, the edge forces both the PoP and the user’s browser to hold onto it.

Rule 2: Strip Asset Query Strings

Those ?ver= parameters kill hit ratios. You want StackPath to ignore query parameters on static files so style.css?ver=6.4 and style.css?ver=6.4.1 hit the exact same cache bucket.

Take a look at our walkthrough on how to ignore query strings in StackPath edge rules so you don’t accidentally cache different dynamic responses under one key.

Preventing Edge Caching on Dynamic and Logged-In Requests

Here’s a trap that will burn you: if you cache full HTML pages without cookie exceptions, the CDN will cache the WordPress admin bar for logged-in users and serve it to anonymous visitors. In WooCommerce, customers will see each other’s shopping carts.

You must make sure dynamic page caching skips users who have active session cookies.

Create an EdgeRule that checks the request cookie:

  • Rule Name: Bypass Cache for WordPress Users
  • If: Request Header Cookie matches regex (wordpress_logged_in|comment_author|wp-postpass|woocommerce_items_in_cart)
  • Then: Set Cache Policy to Bypass Cache

For more edge configurations covering carts, checkout pages, and drafts, read our guide to bypass StackPath CDN cache for logged-in WordPress users.

Verifying Cache Hit Ratios and Debugging Headers

Once DNS settles, hit your CDN endpoint from your terminal to make sure assets are actually serving from edge memory instead of round-tripping to your origin.

Test a static asset through your StackPath hostname:

curl -I -s https://cdn.example.com/wp-content/themes/twentytwentyfour/style.css

Check the headers. On the first pass, you’ll see a cache miss because the edge PoP hasn’t fetched it yet:

HTTP/2 200 date: Mon, 18 Mar 2024 14:35:10 GMT
content-type: text/css
cache-control: max-age=31536000, public, no-transform
x-cache: MISS
x-hw: 1710772510.dop014.fr1.t,1710772510.cds022.fr1.c

Run that same command again immediately:

curl -I -s https://cdn.example.com/wp-content/themes/twentytwentyfour/style.css

Now you should see a hit:

HTTP/2 200 date: Mon, 18 Mar 2024 14:35:12 GMT
content-type: text/css
cache-control: max-age=31536000, public, no-transform
x-cache: HIT
x-hw: 1710772512.dop014.fr1.h,1710772512.cds022.fr1.c

That x-cache: HIT means StackPath served the file straight from RAM at the edge node. On my test, response time dropped from 182ms on the miss to 14ms on the hit. For more details on how browsers handle these responses, check the web.dev HTTP caching guide.

Automating Cache Purging When Assets Change

Setting a 1-year cache means browsers won’t bother checking your server for updates. If you change a CSS file in place without renaming it, existing visitors will see broken styling until their local cache expires. You’ve got two ways to handle updates:

  • Asset Fingerprinting: Configure your build tool (Vite, Webpack, etc.) to append content hashes to filenames (like style.a8f9b2.css). When the file changes, the URL changes, automatically bypassing stale caches everywhere.
  • Programmatic Purges: If you’re hotfixing files directly in WordPress, purge the file URL across StackPath via their REST API.

To purge a specific file across all StackPath PoPs, send a POST request with your API credentials:

curl -X POST "https://gateway.stackpath.com/cdn/v1/stacks/{stack_id}/sites/{site_id}/purge"  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"  -H "Content-Type: application/json"  -d '{ "items": [ { "url": "https://cdn.example.com/wp-content/themes/twentytwentyfour/style.css", "recursive": false } ] }'

You’ll get back a 200 OK, and the edge will grab a fresh copy on the very next hit.

Frequently Asked Questions

Why does my CDN show cache misses even after setting max-age?

CDNs use dozens of regional PoPs worldwide. A user in Frankfurt hits a German node; a user in Dallas hits a Texas node. Each PoP manages its own local cache. Until someone in that specific region requests the file, that local edge node will report a MISS.

Will a 1-year browser cache break my site when I publish a new blog post?

No. Dynamic HTML documents (your homepage, posts, archives) should never get a 1-year max-age. That long TTL is strictly for static assets like images, CSS, JS, and fonts. Your HTML responses should ship with Cache-Control: no-cache or a short edge TTL so new posts show up immediately.

Can I just use a WordPress caching plugin instead of editing .htaccess?

Plugins like WP Rocket or W3 Total Cache can write these rules into .htaccess for you. But putting the rules into your server config manually cuts down plugin bloat and guarantees that files served directly by Apache or Nginx get proper headers without PHP ever waking up.

Does StackPath cache files if the origin returns a 404?

Yes. StackPath caches 404 Not Found errors for a brief window (usually 10 to 60 seconds) to stop missing files or DDoS scans from hammering your origin. You can tweak negative caching durations in your StackPath site settings.

Next Steps

With static assets cached across edge PoPs and browser storage, your origin only spends CPU cycles on actual HTML generation. From here, keep optimizing your CDN setup by reading our guide on how to ignore query strings in StackPath CDN edge rules to push your cache hit ratio even higher.

all_in_one_marketing_tool