When a WordPress or PHP page is slow, the fastest fix is usually not to run the code at all. Caching stores the result of expensive work so the next visitor gets it instantly. Done well, it cuts server response time from a second to tens of milliseconds. Done badly, it shows one customer another customer's cart.
This guide goes through the layers from the visitor inward, what each one does, and how to clear it safely.
The layersLink to this section
Visitor's browser ─ HTTP cache (Cache-Control)
│
CDN edge ─ cached static files, sometimes HTML
│
Web server ─ full-page cache (Nginx FastCGI cache, Varnish, plugin)
│
PHP ─ OPcache (compiled code)
│
WordPress / app ─ object cache (Redis), transients
│
Database ─ query results, buffer poolEach layer closer to the visitor saves more work, but is harder to clear and more dangerous for personalised content.
1. Browser caching with HTTP headersLink to this section
Static assets such as CSS, JavaScript, fonts and images should be cached by the browser for a long time. The trick is versioned file names, so a long cache never serves an old file after a deploy. WordPress adds ?ver= query strings to enqueued assets; build tools and Next.js add content hashes to file names.
# Versioned static assets: cache for a year
location ~* \.(css|js|woff2|svg|webp|avif|jpg|png)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}HTML is different: it changes when content changes, so either don't let browsers cache it (no-cache, which means "revalidate before using") or cache it for a short time. Let the server-side cache do the heavy lifting.
2. CDNLink to this section
A CDN serves static files from locations near the visitor and absorbs traffic spikes. Most also cache HTML if you allow it, which works well for content sites where pages are the same for everyone. Set rules so the CDN bypasses caching for:
- logged-in users (WordPress sets
wordpress_logged_in_*cookies), - WooCommerce cart, checkout and account pages, and any request with
woocommerce_items_in_cart, - admin,
wp-login.php, REST API requests that change data, and previews.
3. Full-page cachingLink to this section
A full-page cache stores the complete HTML of a page and serves it without starting PHP or touching the database. For anonymous visitors on a content site, this is the single biggest improvement you can make. Options:
- Host-level caching (managed WordPress hosts, Nginx FastCGI cache, Varnish). Fastest, and usually integrated with purge on publish.
- A caching plugin that writes static HTML files. Good on shared hosting.
A minimal Nginx FastCGI cache:
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=WP:100m inactive=60m;
map $http_cookie $skip_cache {
default 0;
~*wordpress_logged_in_ 1;
~*woocommerce_items_in_cart 1;
~*comment_author_ 1;
}
server {
# ...
set $no_cache $skip_cache;
if ($request_method = POST) { set $no_cache 1; }
if ($request_uri ~* "/(cart|checkout|my-account|wp-admin|wp-login\.php)") { set $no_cache 1; }
location ~ \.php$ {
fastcgi_cache WP;
fastcgi_cache_valid 200 301 10m;
fastcgi_cache_bypass $no_cache;
fastcgi_no_cache $no_cache;
add_header X-Cache $upstream_cache_status;
# ... usual fastcgi_pass settings
}
}The X-Cache header (HIT, MISS, BYPASS) lets you confirm what's happening with curl -I.
InvalidationLink to this section
Clear cached pages when content changes: on publish, update, comment approval and menu changes. Host caches and plugins usually do this automatically; with a custom Nginx cache you need a purge module or a plugin that integrates with it. A short fastcgi_cache_valid time is a safety net, not a strategy.
4. OPcacheLink to this section
PHP compiles every file on every request unless OPcache keeps the compiled code in memory. It's included with PHP and should always be on in production:
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=60On deployment-based setups (no editing files on the server), you can set validate_timestamps=0 for a little more speed and reset OPcache on each deploy.
5. Object cachingLink to this section
WordPress has an object cache API (wp_cache_get/wp_cache_set) that core uses for options, posts, terms and meta. By default it only lasts for one request. A persistent object cache with Redis or Memcached keeps it across requests, which helps exactly where page caching can't: logged-in users, the admin, carts, checkouts, and API requests.
Install Redis on the server and a drop-in plugin such as Redis Object Cache, then confirm the hit rate in its dashboard. Use object caching in your own code for expensive lookups:
function acme_featured_properties(): array {
$cached = wp_cache_get( 'featured_properties', 'acme' );
if ( false !== $cached ) {
return $cached;
}
$ids = get_posts( array( 'post_type' => 'property', 'meta_key' => 'featured', 'meta_value' => '1', 'fields' => 'ids', 'posts_per_page' => 12 ) );
wp_cache_set( 'featured_properties', $ids, 'acme', HOUR_IN_SECONDS );
return $ids;
}
// Clear it when a property changes
add_action( 'save_post_property', fn () => wp_cache_delete( 'featured_properties', 'acme' ) );TransientsLink to this section
Transients store a value with an expiry. Without a persistent object cache they live in wp_options; with one, they go to Redis. Use them for results of remote API calls or heavy queries:
$rates = get_transient( 'acme_exchange_rates' );
if ( false === $rates ) {
$response = wp_remote_get( 'https://api.example.com/rates', array( 'timeout' => 5 ) );
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
$rates = json_decode( wp_remote_retrieve_body( $response ), true );
set_transient( 'acme_exchange_rates', $rates, 30 * MINUTE_IN_SECONDS );
}
}Never assume a transient exists: it can expire or be evicted at any time.
In Laravel and other PHP appsLink to this section
The same layers apply. Laravel's Cache::remember() with Redis plays the role of the object cache, and route, config and view caching remove framework overhead. The Eloquent performance guide covers query-level caching.
What must never be cachedLink to this section
- Pages that show personal data: cart, checkout, account, order confirmation.
- Responses that set cookies or contain nonces tied to a user. WordPress nonces in cached pages expire after about a day and cause "link expired" errors for forms; exclude those pages or load the nonce with an uncached AJAX request.
- Admin and login pages, previews and
POSTrequests.
Testing your cachingLink to this section
# Response headers for a page, twice: the second request should be a HIT
curl -sI https://www.example.com/ | grep -iE "cache|age|x-cache"
curl -sI https://www.example.com/ | grep -iE "cache|age|x-cache"Check a page as an anonymous visitor, as a logged-in user and with an item in the cart. Each should get the behaviour you expect. If dates in headers look odd, the timestamp converter helps compare Age and Last-Modified values.
Choosing a setup by type of siteLink to this section
The right combination depends on how personal the pages are and how often they change:
| Site | Page cache | Object cache | Notes |
|---|---|---|---|
| Brochure or blog | Yes, long TTL with purge on publish | Optional | Static HTML can be served for almost every request |
| News or busy blog | Yes, short TTL plus purge | Yes | Purge home, category and feed pages when a post is published |
| WooCommerce store | Yes, except cart, checkout, account | Yes, important | Bypass on cart cookies; see the WooCommerce speed guide |
| Membership or LMS | Only for logged-out pages | Yes, essential | Most traffic is logged in, so the object cache does the work |
| Headless frontend | At the frontend or CDN | Yes, on WordPress | WordPress serves the API; cache API responses and revalidate on change |
For headless sites, the frontend has its own caching: Next.js caches fetched data and pages and revalidates them on a timer or when WordPress sends a webhook. WordPress still benefits from an object cache, because every API request it serves runs PHP.
Rolling out caching safelyLink to this section
Enable layers one at a time and check each before adding the next:
- Turn on OPcache and confirm it's working with
opcache_get_status(). - Add the object cache and watch error logs and the hit rate for a day.
- Add page caching with conservative bypass rules, then test anonymous, logged-in and cart journeys.
- Lengthen cache times only after purging is confirmed to work.
Doing them all at once makes it hard to tell which layer causes a problem.
Common problemsLink to this section
- Changes don't appear after publishing. One of the layers wasn't purged. Check headers to find which layer served the page.
- Visitors see someone else's cart or name. A personalised page was cached. Add cookie and URL bypass rules immediately and purge everything.
- Forms show "link has expired". Cached pages contain stale nonces. Exclude form pages or fetch nonces dynamically.
- Object cache made things slower. Redis is remote or overloaded, or a plugin stores huge values. Check latency and key sizes.
- Redirect loops after enabling a CDN. HTTPS is terminated at the CDN while WordPress thinks the request is HTTP. Configure the forwarded protocol header, and review redirect rules with the redirect generator.
Frequently asked questionsLink to this section
Do I need both a page cache and an object cache?Link to this section
On busy sites, yes. The page cache serves anonymous visitors; the object cache speeds up everything that can't be page-cached.
Is a caching plugin enough?Link to this section
On shared hosting it's often the best available option. On a VPS or managed host, server-level caching is faster and usually more reliable.
How long should HTML be cached?Link to this section
With purge-on-publish, hours or longer. Without reliable purging, a few minutes.
Does caching help Core Web Vitals?Link to this section
Yes, mainly LCP, by cutting server response time (TTFB). The Core Web Vitals guide covers the other fixes.
Need caching set up or debugged? Get in touch.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.