Page speed problems on WordPress, Laravel or modern SPA frontends are often blamed on PHP, the database or the hosting plan. In reality, a surprising amount of “slowness” comes from missing or incorrect browser caching. Every time a visitor reloads your site, their browser must decide: “Can I reuse what I already have, or do I need to hit the server again?” That decision is entirely driven by HTTP caching headers and, if you use one, your CDN’s cache rules.
In this article we’ll look at how to correctly use Cache-Control, ETag and CDN cache rules for real-world WordPress, Laravel and SPA (React, Vue, Angular) projects. We’ll keep the focus practical: which files should get long-lived caching, which responses should stay dynamic, and how you avoid the classic “I updated CSS but users still see the old version” problem. At dchost.com we tune these settings daily on shared hosting, VPS and dedicated servers, so the examples below reflect what actually works in production, not just what the RFC says.
İçindekiler
- 1 Why HTTP Caching Matters So Much for Modern Sites
- 2 Core HTTP Caching Concepts You Really Need
- 3 Browser Caching Strategy for WordPress
- 4 Browser Caching Strategy for Laravel
- 5 Browser Caching Strategy for SPA Frontends (React, Vue, Angular)
- 6 How CDN Cache Rules Interact with Browser Cache
- 7 Practical Checklist and Common Gotchas
- 8 Bringing It All Together (and What to Do Next)
Why HTTP Caching Matters So Much for Modern Sites
HTTP caching is the cheapest performance optimization you can deploy:
- Browsers load faster because static assets (CSS, JS, images, fonts) are reused locally instead of fetched on every page view.
- Your origin server handles fewer requests, leaving more CPU and RAM for real dynamic work (search, checkout, dashboards).
- CDNs become dramatically more effective because they can keep assets at the edge longer, improving global latency and reducing bandwidth bills.
The good news: for most WordPress, Laravel and SPA projects, you don’t need complex logic. You mainly need three layers done right:
- Correct Cache-Control (and sometimes Expires) headers on your origin.
- Sensible ETag / Last-Modified behavior for conditional requests.
- CDN cache rules that respect your origin headers but override them where necessary (especially for HTML and APIs).
If you want a deeper conceptual dive specifically into headers like immutable, ETag vs Last-Modified and fingerprinting, we also have a dedicated article: Stop Fighting Your Cache: Cache-Control immutable, ETag vs Last‑Modified, and Asset Fingerprinting. Here we’ll stay focused on practical recipes per framework and how to combine them with a CDN.
Core HTTP Caching Concepts You Really Need
Cache-Control: the main steering wheel
Cache-Control is the primary HTTP/1.1 header used by browsers and CDNs to decide whether and for how long a response can be cached.
Typical directives you’ll use:
- max-age=N: how long (in seconds) the browser may reuse the response without re-checking. Example:
max-age=31536000(1 year) for versioned static assets. - public: response can be cached by any cache (browser, CDN, proxy). Use this for assets and anonymous pages.
- private: response is specific to a single user (e.g. a profile page); only the user’s browser should cache it.
- no-store: do not cache or store this response anywhere (login pages, sensitive dashboards, banking-like data).
- no-cache: can be stored, but must be revalidated before reuse. Often combined with ETag/Last-Modified for dynamic HTML.
- must-revalidate: once expired, the cache must contact the origin again before serving the response.
- s-maxage=N: like max-age, but only for shared caches (CDNs, proxies). Browsers ignore it.
- immutable: tells modern browsers that this response will never change during its lifetime. Perfect for fingerprinted assets.
Example for a hashed JS bundle in a SPA:
Cache-Control: public, max-age=31536000, immutable
Example for a dynamic HTML page where you want conditional requests:
Cache-Control: no-cache, must-revalidate
ETag and conditional requests
ETag is a unique identifier for a specific version of a resource. The browser stores it together with the response. On the next request, the browser adds:
If-None-Match: "the-etag-value"
If the content hasn’t changed, the server answers:
HTTP/1.1 304 Not Modified
The browser then reuses its cached copy. This is called a conditional request. Data transfer is minimal, and you avoid re-sending content unnecessarily.
There are two common pitfalls:
- Auto-generated ETags with inode info on some web servers leak machine-specific details and can break across clusters. On multi-server setups, configure “weak” or content-hash based ETags instead.
- CDNs sometimes ignore or strip ETags if misconfigured. You can rely on Last-Modified in those cases, or configure the CDN to respect ETags.
Last-Modified and 304 responses
Last-Modified is simpler: it’s just a timestamp of when the resource last changed:
Last-Modified: Tue, 10 Dec 2024 13:37:00 GMT
The browser then sends:
If-Modified-Since: Tue, 10 Dec 2024 13:37:00 GMT
If the content hasn’t changed, the server returns 304 Not Modified just like with ETag.
You can use either ETag or Last-Modified or both. In many WordPress and Laravel deployments, using Last-Modified based on the file modification time (for static assets) or article updated date (for posts/pages) is perfectly fine.
Browser Caching Strategy for WordPress
WordPress mixes very different content types: static assets from themes/plugins, dynamic HTML, and potentially WooCommerce cart/checkout flows. Each needs different caching behavior.
Static assets: CSS, JS, images, fonts
Goal: cache aggressively, ideally for months or a year, but only if you pair that with asset fingerprinting (versioned filenames).
Examples of URLs that should be heavily cached:
/wp-content/themes/yourtheme/style.css?ver=1.2.3/wp-content/plugins/your-plugin/assets/js/frontend.min.js?ver=2.5.0/wp-content/uploads/2025/01/hero-banner.webp- Google Fonts or self-hosted fonts under
/wp-content/uploads/fonts/...
On a typical Apache-based WordPress hosting, you can add this to .htaccess:
<IfModule mod_expires.c>
ExpiresActive On
# Images
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
# CSS, JS
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
# Fonts
ExpiresByType font/woff2 "access plus 1 year"
</IfModule>
<IfModule mod_headers.c>
<FilesMatch ".(css|js|png|jpe?g|gif|webp|svg|woff2?)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
On Nginx (for example on a dchost.com VPS or dedicated server), a similar rule:
location ~* .(?:css|js|jpe?g|png|gif|webp|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
This assumes that when you deploy a new version, WordPress (or your build pipeline) changes the file names or query string versions. If you’re still manually editing style.css without versioning, consider moving to a proper asset pipeline or at least incrementing ?ver= on each release.
Dynamic HTML: posts, pages and WooCommerce
For the main HTML of WordPress pages you have three layers to think about:
- Full-page caching (e.g. Nginx FastCGI cache, LiteSpeed Cache, or a WordPress cache plugin).
- HTTP caching headers (Cache-Control, ETag/Last-Modified).
- CDN HTML caching rules (if you cache HTML at the edge).
If you are not caching HTML at the browser/CDN level (only at server level), you can keep headers conservative:
Cache-Control: no-cache, must-revalidate
and still gain benefits from conditional requests via Last-Modified or ETag. But if you want browsers and CDNs to cache HTML for anonymous visitors, you must be more careful:
- Do cache category pages, blog posts, landing pages for logged-out users.
- Do not cache
/wp-admin/,/wp-login.php, WooCommerce cart/checkout, or pages showing personalized content.
For WooCommerce in particular, we recommend pairing this article with our CDN-focused guide The CDN Caching Playbook for WordPress and WooCommerce, where we go deep into HTML caching vs checkout safety.
Example: safe HTML caching for anonymous visitors
A pragmatic WordPress pattern:
- Use a page cache (server or plugin) for anonymous users.
- Serve cached HTML with:
Cache-Control: public, max-age=300, s-maxage=600
This tells:
- Browsers: you may reuse this HTML for 5 minutes.
- CDNs (shared caches): you may reuse it for 10 minutes.
At the same time, instruct your CDN (Cloudflare, etc.) to bypass cache for:
/wp-admin/*/wp-login.php/cart/*,/checkout/*,/my-account/*and any URLs that set cart or session cookies.
For more detailed WooCommerce-safe rules, see our guide to CDN caching rules for WordPress and WooCommerce HTML.
Browser Caching Strategy for Laravel
Laravel gives you more explicit control over routes and headers, but the caching principles are the same. Think in terms of three categories:
- Static assets built by your frontend pipeline (Mix, Vite, Webpack).
- Blade-rendered HTML pages (classic MVC).
- JSON APIs (for SPAs or mobile apps).
Static assets: let Vite/Mix and Nginx do the heavy lifting
Modern Laravel setups with Vite or Mix already produce fingerprinted filenames like app.87a3f1ab.js. That’s perfect for aggressive caching. Configure your web server (or CDN) similarly to the WordPress examples:
location /build/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
Because filenames change on every build, you can safely cache them for a year. When you deploy a new release, the old file is no longer referenced by HTML, so it doesn’t matter that some visitors still have it cached.
Blade HTML: conservative, but smart
For pages like dashboards, profile pages, admin panels, or any content that might be user-specific, prefer:
Cache-Control: private, no-cache, must-revalidate
You can set this via middleware, for example:
public function handle($request, Closure $next)
{
$response = $next($request);
return $response->header('Cache-Control', 'private, no-cache, must-revalidate');
}
For fully public content like a marketing site built on Blade, you can cache similar to WordPress anonymous HTML: set public, max-age=300, s-maxage=600 and let your CDN handle edge caching of those pages.
Laravel JSON APIs: when to cache and when not to
APIs fall into three common buckets:
- Read-only, rarely changing (e.g. countries list, static configuration, pricing table).
- Read-mostly but updated regularly (e.g. product catalog, blog listing).
- User-specific or highly dynamic (e.g.
/me, notifications, cart APIs).
Example headers:
- Static config:
Cache-Control: public, max-age=86400, immutable - Catalog:
Cache-Control: public, max-age=300, s-maxage=600 - User-specific:
Cache-Control: private, no-storeorprivate, no-cache
Remember: if your SPA or frontend relies heavily on AJAX/Fetch, these HTTP headers still apply to those JSON responses, both in the browser and at the CDN edge.
Browser Caching Strategy for SPA Frontends (React, Vue, Angular)
SPAs behave differently from classic multi-page apps: one index.html shell loads JS bundles that then take over routing on the client side. The caching strategy that works well here is:
- Short-lived cache for
index.html(the shell). - Long-lived, immutable cache for hashed assets (JS, CSS, fonts, images).
index.html: treat it like dynamic HTML
Your index.html file contains references to the current JS/CSS asset versions and is the entry point to your app. You want users to pick up new releases reasonably fast, so cache it only for a few minutes:
Cache-Control: public, max-age=300, must-revalidate
Some teams go even more conservative and use no-cache (still allowing conditional requests and 304 responses). That’s fine too, especially during rapid development phases.
JS/CSS bundles and assets: immutable, versioned, long-lived
Bundles like main.3f2b9a87.js, vendors.0f91c2a4.css should be served with:
Cache-Control: public, max-age=31536000, immutable
And the same for images and fonts under your SPA’s /assets/ or /static/ directory. Your build pipeline (Vite, Webpack, etc.) should already generate hashed filenames; if not, that’s the first upgrade to plan.
For a more advanced discussion of immutable and fingerprinting, we go deeper in our asset fingerprinting and Cache-Control guide.
Service workers: powerful, but not a magic bullet
Many SPA stacks offer service workers (PWA features). These can implement custom caching strategies, but they also add complexity. A few practical tips:
- Start with good HTTP caching; then add service workers only where they provide clear value (offline mode, advanced prefetching).
- Ensure your service worker has a reliable versioning and update strategy so users don’t get stuck with old bundles.
- Be cautious about caching HTML/API responses inside service workers; you can easily override or conflict with your HTTP/ETag logic.
How CDN Cache Rules Interact with Browser Cache
Putting your site behind a CDN introduces a second cache layer: the edge cache. The browser still caches responses, but now it often gets them from a nearby CDN PoP instead of your origin server. To get the best of all worlds, you need to think about three actors:
- Origin server: sets Cache-Control, ETag, Last-Modified.
- CDN: respects or overrides those headers, decides what to keep at edge.
- Browser: caches according to headers received (usually from the CDN).
Origin vs edge: who is in charge?
CDNs typically offer three broad modes:
- Respect origin headers: CDN uses whatever Cache-Control/Expires the origin sends. Easiest, but sometimes too conservative or too aggressive.
- Override with CDN rules: CDN ignores or modifies origin headers; you configure TTLs per path, file type, or response code.
- Hybrid: use origin headers generally, but apply exceptions (e.g. “cache HTML for 5 minutes even if origin says no-cache”).
For many WordPress and Laravel sites, we like a hybrid approach:
- Let origin define caching for static assets (long-lived, immutable).
- Use CDN rules primarily for HTML and APIs where you want carefully controlled edge TTL and bypass paths.
For a detailed, practical look at CDN TTLs, cache keys and bandwidth savings, see our CDN caching playbook for WordPress and WooCommerce.
Essential CDN cache rules for WordPress
For a typical WordPress + WooCommerce site, we often configure CDN rules along these lines:
- Cache static assets (
.css,.js,.jpg,.webp,.svg,.woff2) with long TTL (e.g. 1 month at edge) and honor origin headers if they are even longer. - Cache HTML for anonymous users for a short time (1–10 minutes), with “ignore cookies” for known non-critical cookies (e.g. analytics).
- Bypass cache completely for:
/wp-admin/*,/wp-login.php/cart/*,/checkout/*,/my-account/*- Any URL with WooCommerce or session cookies present.
Combined with well-tuned origin headers, this yields:
- Blazing-fast initial page loads from the CDN edge.
- Safe handling of carts, logins and account pages.
- Lower origin load and better TTFB metrics.
CDN rules for Laravel and APIs
For Laravel, CDN rules can be more granular because you control routes explicitly:
- Cache
/assets/*or/build/*aggressively; rely on hashed filenames. - Cache marketing routes like
/pricing,/about,/blogat edge for a few minutes. - Bypass or keep very low TTLs on
/api/*endpoints that are user-specific or time-sensitive. - For public APIs that can be cached, configure a cache key that respects query strings but ignores unnecessary cookies.
Advanced CDNs allow you to enable stale-while-revalidate and stale-if-error at the edge, serving slightly stale content while fetching a fresh copy in the background or while your origin is having issues. If you want to see this pattern in depth on Nginx, Cloudflare and WordPress, we cover it in our guide to stale-while-revalidate and stale-if-error.
CDN rules for SPA frontends
For SPAs, CDN and browser caching work extremely well together:
- index.html: short edge TTL (1–5 minutes), respect origin
max-age. - Hashed bundles & assets: long edge TTL (weeks or months),
immutablefrom origin. - APIs under /api/: separate cache rules per endpoint; many will be no-cache or very short TTL.
Because SPAs offload most navigation to the browser, ensuring that bundles are served from a local cache or a nearby CDN PoP has a huge impact on the perceived “snappiness” of the app.
Practical Checklist and Common Gotchas
Before you roll these changes across production, walk through this short checklist. We regularly use something like this when optimizing sites on dchost.com VPS and dedicated servers.
1. Separate assets from HTML clearly
- Ensure your theme / build pipeline outputs assets into predictable directories (
/wp-content/themes/.../assets/,/build/,/static/). - Apply long-lived, immutable caching only to those asset paths, not to HTML or API routes.
2. Implement asset fingerprinting
- For WordPress, many performance plugins or build tools can handle versioned filenames or
?ver=query parameters. - For Laravel and SPAs, enable hashed filenames in Vite/Webpack builds.
- Once fingerprinting is in place, you can confidently set
max-age=31536000, immutableon those files.
3. Decide your HTML strategy
- Minimalist: server-side full-page cache only, browsers always revalidate HTML (safe, simpler).
- Balanced: 1–10 minute HTML cache at browser and CDN for anonymous users, with careful bypasses.
- Advanced: use stale-while-revalidate, cache tags, smarter invalidation logic.
Whichever you choose, document it so your team knows which headers should be present on each type of response.
4. Configure CDN cache keys and exclusions
- Cache key should include path + query string for most static assets and HTML pages.
- Ignore cookies that do not affect content (analytics, A/B testing identifiers where safe).
- Never cache when authentication/session cookies are present unless you are absolutely sure the content is identical for all users.
5. Test with DevTools and curl
Use browser DevTools (Network tab) and curl -I to verify behavior:
- Check Cache-Control, ETag and Last-Modified on each type of resource.
- Reload pages to see whether you get
200(from network) or(from disk cache)/(from memory cache)in DevTools. - Send
If-None-MatchorIf-Modified-Sincemanually to verify 304 behavior.
6. Monitor real-world impact
- Re-run PageSpeed Insights or WebPageTest and compare TTFB, largest contentful paint (LCP) and overall transfer size.
- Monitor origin CPU, RAM and bandwidth on your hosting panel or with tools like Netdata/Prometheus to see reduced load.
- Pay attention to error logs during deploys to ensure cache invalidation works as expected.
If you’d like a broader view of how hosting choices affect Core Web Vitals, see our article How HTTP/2 and HTTP/3 (QUIC) Affect SEO and Core Web Vitals.
Bringing It All Together (and What to Do Next)
Getting Cache-Control, ETag and CDN cache rules right is less about memorizing every directive and more about drawing a clear line between immutable assets, semi-dynamic HTML/APIs and truly dynamic, user-specific data. Once you label each part of your WordPress, Laravel or SPA stack that way, the actual headers almost write themselves.
On the asset side, fingerprinted filenames plus max-age=31536000, immutable give you instant wins in repeat visits. On the HTML/API side, short max-age or no-cache combined with ETag/Last-Modified and, optionally, CDN features like stale-while-revalidate give you a good balance between freshness and performance. If you pair this with a well-chosen CDN edge strategy and solid hosting resources (whether on shared hosting, VPS, dedicated or colocation with dchost.com), you can serve fast experiences consistently, even under load.
If you want to go deeper, we recommend reading our introduction to when a CDN really makes sense and combining it with the caching guides linked above. And if you’re planning a new architecture or migration, our team at dchost.com can help you choose a hosting setup where your caching strategy, PHP stack and CDN all work together instead of fighting each other.
