Technology

Browser and CDN Caching for WordPress, Laravel and SPAs

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 depends on HTTP caching headers, the browser’s request and cache state, and any service worker involved. CDN cache rules govern reuse at the edge and may also affect the headers sent to the browser.

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. The examples cover configuration choices for shared hosting, a VPS or a dedicated server; which settings you can change depends on your server access and hosting configuration.

Why HTTP Caching Matters So Much for Modern Sites

HTTP caching can be a cost-effective performance optimization:

  • 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 can serve more requests from cache because they can keep reusable assets at the edge longer, reducing origin traffic and potentially lowering latency and bandwidth costs.

The good news: for most WordPress, Laravel and SPA projects, you don’t need complex logic. You mainly need three layers done right:

  1. Correct Cache-Control (and sometimes Expires) headers on your origin.
  2. Sensible ETag / Last-Modified behavior for conditional requests.
  3. CDN cache rules that respect your origin headers, with deliberate overrides only for responses confirmed safe for shared caching (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: the response’s freshness lifetime in seconds. Example: max-age=31536000 (1 year) for versioned static assets.
  • public: permits shared-cache storage. Use this only for responses suitable for sharing, such as public assets and non-personalized anonymous pages.
  • private: permits storage in private caches, such as a browser cache, but prohibits shared-cache storage. It does not by itself prohibit local storage of sensitive data.
  • no-store: instructs HTTP caches not to store this response (appropriate for login pages and sensitive dashboards). It does not prevent all application-level disk or memory storage.
  • no-cache: can be stored, but must be revalidated before reuse. Often combined with ETag/Last-Modified for dynamic HTML.
  • must-revalidate: once stale, the cached response must not be reused without successful validation.
  • s-maxage=N: overrides max-age for shared caches (CDNs, proxies). Browsers ignore it.
  • immutable: tells supporting caches that the response will not change while it is fresh. Perfect for fingerprinted assets.

Example for a hashed JS bundle in a SPA:

Cache-Control: public, max-age=31536000, immutable

Example for non-personalized dynamic HTML where storage is acceptable but reuse requires validation:

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. When revalidating that cached response with a conditional GET, 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, use validators that remain consistent across nodes for the same representation, such as content-based ETags. Merely marking a node-specific ETag “weak” does not make it consistent.
  • 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

When revalidating with this timestamp, the browser 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. For static assets, file modification time can be a useful Last-Modified value if it reliably tracks changes. For rendered posts/pages, an article’s updated date alone is insufficient when templates, comments or other dependencies can change the response. Use a validator that accounts for the rendered representation, or omit it if you cannot compute one reliably.

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 versioning (preferably fingerprinted filenames, or query-string versions that change with the content and remain part of the cache key).

Examples of asset URLs are listed below. Long-lived caching is appropriate only if their URLs change whenever the content changes; a dated upload path alone does not guarantee this. For third-party Google Fonts URLs, the third party controls the response headers.

  • /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/...

For an Apache-based WordPress hosting setup, first confirm that the required modules and .htaccess overrides are available. Apply the following only within a public, versioned static-asset directory: the rules do not check whether a filename is fingerprinted or a version query parameter is present. Do not apply them site-wide to mutable uploads or protected files.

<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>

For a self-managed Nginx setup, the following rule is intended for a server block serving only public, versioned static assets. It is not a drop-in rule for a mixed WordPress server block, where location matching and protected routes also need review: This example serves static files directly. expires off prevents the expires module from generating another max-age; add_header supplies the explicit policy. Review inherited add_header settings, required security headers and headers added by other modules or the CDN; off does not strip existing headers. Validate the configuration and inspect origin/CDN responses for one coherent Cache-Control policy before deployment.

location ~* [.](?:css|js|jpe?g|png|gif|webp|svg|woff2?)$ {
    expires off;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

This assumes that every changed asset gets a new URL and that pages reference that new URL. If you use query-string versions, ensure that the CDN includes them in its cache key. Apply this to images and fonts as well as CSS and JS. If you’re still manually editing style.css without versioning, consider moving to a proper asset pipeline or at least changing ?ver= whenever the file changes.

Dynamic HTML: posts, pages and WooCommerce

For the main HTML of WordPress pages you have three layers to think about:

  1. Full-page caching (e.g. Nginx FastCGI cache, LiteSpeed Cache, or a WordPress cache plugin).
  2. HTTP caching headers (Cache-Control, ETag/Last-Modified).
  3. CDN HTML caching rules (if you cache HTML at the edge).

For non-personalized WordPress HTML that may be stored by browsers or CDNs but must be validated before reuse, you can use the following headers. They do not prohibit storage; sensitive responses need no-store, and responses intended only for a user’s browser need private:

Cache-Control: no-cache, must-revalidate

This still permits storage, but requires validation before reuse, allowing conditional requests via Last-Modified or ETag. To allow browsers and CDNs to reuse anonymous HTML without validation while it is fresh, restrict caching to non-personalized responses:

  • Do cache category pages, blog posts and landing pages for logged-out users only when the responses contain no personalized or session-specific content.
  • 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: HTML caching for non-personalized anonymous pages

A pragmatic WordPress pattern:

  • Use a page cache (server or plugin) for anonymous GET requests only when the response is suitable for sharing and contains no personalized data or session-specific tokens.
  • Serve only those eligible cached HTML responses with:
Cache-Control: public, max-age=300, s-maxage=600

This tells:

  • Browsers: a freshness lifetime of 5 minutes.
  • CDNs (shared caches): a freshness lifetime of 10 minutes, if the response is eligible for edge caching.

These are freshness lifetimes, not guaranteed retention times. Time already spent in a cache counts toward the response’s age; receiving it from a CDN does not restart the browser’s freshness lifetime.

At both the origin page cache and the CDN, bypass cache for the following paths. Include each path itself, its descendants and any store-specific equivalents:

  • /wp-admin/*
  • /wp-login.php
  • /cart/*, /checkout/*, /my-account/* and any URLs that set cart or session cookies.
  • Requests with login, cart or session cookies, including requests to otherwise public pages.
  • Store-specific dynamic/API endpoints and query-string actions that change cart or session state.

Do not store session-setting or personalized responses in shared caches or send them with the public HTML policy above. Cache bypass must prevent both reuse of a stored page and storage of the personalized response.

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

For aggressive caching, use fingerprinted asset filenames like app.87a3f1ab.js and confirm that your Vite or Mix build produces a new asset URL when its content changes. Configure your web server (or CDN) similarly to the WordPress examples. The following location is appropriate only if every file served under /build/ has a content-versioned URL; mutable manifests and other unversioned files need a separate, shorter-lived policy.

This is a direct static-file example. expires off disables expiry headers generated by that module; it does not remove headers already added elsewhere. Review add_header inheritance and required security headers, and inspect origin/CDN responses for one coherent policy before deployment.

location /build/ {
    expires off;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

With content-based fingerprinting, changed assets receive new URLs and can be cached for a year without overwriting an existing cached version. Unchanged assets need not receive new filenames on every build. Deploy the updated HTML with the new assets, and retain previous asset versions while cached HTML or open pages may still reference them, including lazily loaded bundles.

Blade HTML: conservative, but smart

For dashboards, profile pages, admin panels or other user-specific HTML, the following policy permits browser storage but excludes shared caches and requires validation before reuse. If the response should not be stored even in the browser cache, use no-store instead:

Cache-Control: private, no-cache, must-revalidate

For Laravel HTML responses, the middleware method can look like this; import Closure in the containing class:

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 only on shareable responses without personalized content, session-specific tokens or session-setting cookies. Configure CDN HTML eligibility and the same session and authentication bypasses rather than assuming the header alone enables safe edge caching.

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:

  • Public static config that will not change at this URL during the one-day freshness lifetime: Cache-Control: public, max-age=86400, immutable
  • Public, non-personalized catalog: Cache-Control: public, max-age=300, s-maxage=600
  • User-specific: Cache-Control: private, no-store to prevent cache storage, or private, no-cache if browser storage with validation before reuse is acceptable.

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

Apply the same policy to images and fonts under your SPA’s /assets/ or /static/ directory only when their URLs are content-versioned too. Confirm the generated output: not every asset is necessarily fingerprinted by the build pipeline. Give unversioned files a shorter-lived or revalidation-based policy until fingerprinting is in place.

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. HTTP Cache-Control headers alone do not govern storage or deletion in the worker’s Cache API: explicitly exclude personalized responses and clean up obsolete versions in the worker 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:

  1. Origin server: sets Cache-Control, ETag, Last-Modified.
  2. CDN: respects or overrides those headers, decides what to keep at edge.
  3. 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 such as a shorter edge TTL for selected public HTML pages. Keep authentication, session and personalized-response exclusions in force.

For many WordPress and Laravel sites, we like a hybrid approach:

  • Let origin define caching for static assets (long-lived and immutable only when content-versioned).
  • 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, a starting point for CDN rules is:

  • Cache static assets (.css, .js, .jpg, .webp, .svg, .woff2) with a long TTL (e.g. 1 month at edge) when they are public and versioned. Honor origin caching restrictions; a file extension alone does not establish cacheability.
  • Cache public HTML for anonymous users for a short time (1–10 minutes), only when it is independent of visitor sessions. Ignore only cookies confirmed not to affect the response (e.g. analytics cookies used solely for measurement).
  • Bypass cache completely for:
  • /wp-admin/*, /wp-login.php
  • /cart/*, /checkout/*, /my-account/*
  • Requests carrying login, WooCommerce cart or session cookies, or authorization credentials.
  • The actual cart, checkout and account URLs if customized, plus store-specific dynamic/API routes and cart-changing requests.
  • Personalized responses and responses that establish sessions; do not override origin privacy or no-storage restrictions.

Combined with well-tuned origin headers and verified bypasses, the intended benefits are:

  • Faster initial page loads when public HTML is served from the CDN edge.
  • Keeping carts, logins and account pages out of shared caches.
  • Reduced origin work and potentially better TTFB on cache hits.

CDN rules for Laravel and APIs

For Laravel, CDN rules can be more granular because you control routes explicitly:

  • Cache public assets under /assets/* or /build/* aggressively only when each file has a content-versioned URL. Exclude mutable manifests, unversioned files and protected responses from that policy.
  • Cache marketing routes like /pricing, /about, /blog at edge for a few minutes only if the responses are public and do not vary by session.
  • Bypass shared caching on /api/* endpoints that are user-specific. For public, time-sensitive endpoints, use a short TTL only if that staleness is acceptable; otherwise bypass caching.
  • For public APIs that can be cached, configure a cache key that respects query strings and other response variants, such as language headers, but ignores only cookies confirmed not to affect the response.

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: use a short edge TTL (1–5 minutes) only when origin policy permits it. Respect origin max-age and any revalidation or no-storage restrictions.
  • Hashed bundles & assets: long edge TTL (weeks or months), immutable from origin.
  • APIs under /api/: separate cache rules per endpoint. Bypass shared caching for user-specific responses; public responses may use revalidation or a short TTL according to freshness needs.

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.

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 public, fingerprinted assets whose contents will not change at the same URL, not indiscriminately to entire directories or 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 set max-age=31536000, immutable on public assets, provided every content change creates a new URL. If versioning uses a query parameter, ensure the CDN includes it in the cache key.

3. Decide your HTML strategy

  • Minimalist: server-side full-page caching for eligible public HTML, with browsers revalidating HTML before reuse. Keep personalized responses out of shared page caches.
  • Balanced: 1–10 minute CDN caching for eligible public HTML, with careful session bypasses and browser revalidation. Use a browser TTL only where stale local HTML is acceptable, including across login or cart changes.
  • 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 keys should distinguish the host and path + query string for most static assets and HTML pages, plus any other inputs that change the cacheable response.
  • Ignore cookies that do not affect content (analytics, A/B testing identifiers where safe).
  • Bypass shared caching when authentication/session cookies or authorization credentials are present unless the response is explicitly designed and verified to be public and identical for all users.

5. Test with DevTools and curl

Use browser DevTools (Network tab) to inspect actual resource requests and curl -I for an initial header check. The latter sends HEAD, so also verify normal GET responses rather than assuming identical behavior:

  • Check Cache-Control, ETag and Last-Modified on each type of resource.
  • Reload pages and inspect both status and transfer source in DevTools: 200 alone does not distinguish network delivery from local reuse. Look for (from disk cache) / (from memory cache), or a 304 after successful revalidation. Ensure DevTools has not disabled caching; reloads may trigger revalidation.
  • Send If-None-Match or If-Modified-Since manually 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 measure whether load decreases.
  • Inspect error logs during deploys, and verify that updated pages and assets reach users within the intended freshness window; error logs alone cannot confirm successful invalidation.

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 can reduce transfers on repeat visits. For public HTML/APIs, short max-age or no-cache combined with ETag/Last-Modified and, optionally, CDN features like stale-while-revalidate can balance freshness and performance. Keep user-specific responses out of shared caches; freshness controls alone do not protect private data. Pair this with a suitable CDN edge strategy and hosting capacity sized for cache misses and uncached traffic, then measure performance under representative 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. For a new architecture or migration, verify that the chosen hosting plan gives you the header controls, PHP configuration access and CDN integration options your caching strategy requires.

Frequently Asked Questions

Cache-Control expresses caching policy through directives such as max-age, private, no-cache, no-store and s-maxage. Expires supplies an expiry time rather than those storage and revalidation controls. The article uses Cache-Control as the primary control, with Expires optional; any combined configuration should produce one coherent caching policy.

Both support conditional requests and 304 Not Modified responses. Last-Modified is useful for static files when modification times reliably track changes. ETags identify resource versions and should remain consistent across servers for the same representation. For rendered pages, validators must account for templates, comments and other dependencies—not just the post's updated date. Use either or both, but omit unreliable validators.

Public assets can use public, max-age=31536000, immutable when every content change creates a new URL. Query-string versions must remain part of the CDN cache key, and updated pages must reference the new URLs. Retain older asset versions while cached HTML or open pages may reference them. Give unversioned files shorter freshness lifetimes or require revalidation.

The CDN controls reuse at the edge, while the browser follows the response headers it receives. s-maxage sets shared-cache freshness without setting the browser's lifetime, and time already spent in cache counts toward response age. Use long lifetimes for public, versioned assets and short lifetimes or revalidation for eligible HTML and APIs. Session and authentication bypasses must prevent both shared-cache reuse and storage of personalized responses.
↑