Caching

This article is part of the WordPress guide. Read the introduction.

At last, caching. The source of headaches and broken dreams if not understood, and one of the biggest levers you can pull to make a website faster. It can be the difference between an unusable mess and a heavenly smooth experience. I don’t believe you can call yourself a WordPress developer (or a web developer for that matter) if you don’t have a deep understanding of caching.

This chapter will be a little different than other chapters. The underlying rule I established in the introduction was that I tend to not discuss things outside the scope of a “WordPress deep dive”. However, caching is such an important and wide topic that I’m going to break this rule. I will cover all of the layers of caching most commonly seen in professional WordPress websites. That being said, I will not dive deeply into non-WordPress sections. I will only mention them, and the rest is on you.

But let’s take a step back. What even is caching? Caching is the process of storing copies of data in a temporary, high(er)-speed location (a cache). Its purpose, at the end of the day, is to make your website faster. The most direct positive impact is achieved by caching expensive and long operations, such as database queries, heavy PHP computation, blocking (synchronous) API requests, etc.

Web caching is layered. It starts at the level of PHP execution and ends on your users’ machines. The following sections attempt to walk through these layers from the very bottom to the very top. By the end of this chapter, you should have a decent understanding of all of these layers, and an excellent understanding of the layers specific to WordPress.

1. PHP OPcache

“OPcache improves PHP performance by storing precompiled script bytecode in shared memory, thereby removing the need for PHP to load and parse scripts on each request.” ~ official PHP documentation. This is way outside of the scope of this guide, but as promised, I’m mentioning it.

What it means is that your PHP files won’t have to be parsed and compiled on every request. They are compiled just once and their bytecode is stored in memory. In reality, you’ll usually not have to worry about it unless you’re running WordPress on a VPS. Even then, OPcache seems to be enabled by default in php.ini.

2. WordPress Object Cache

One important global variable in WordPress is $wp_object_cache. It’s an object of class WP_Object_Cache. This object’s job is basically just being an associative array of cached values. Imagine you had some expensive query or heavy computation to perform multiple times during the page load. Instead of having to do them over and over again, you just store their results in this cache and reuse it later.

You should almost never interact with this object directly. Instead, use one of the many helper functions. The most important ones are:

  • wp_cache_get()
  • wp_cache_set()
  • wp_cache_add()
  • wp_cache_replace()
  • wp_cache_delete()
  • wp_cache_flush()

It’s important to understand that none of this is automatic. You have to consciously decide what data to cache and then actually use the appropriate functions. Let’s say you had some expensive database query to make and wanted to cache it in Object Cache. Here’s what that would look like:

PHP
// Start by trying to read the value from the cache.
$result = wp_cache_get( 'expensive_query', 'post-reading-time', false, $found );

// If the value isn't there, do the expensive operation and cache it.
if ( ! $found ) {
    $result = $wpdb->get_col( $query );
    wp_cache_set( 'expensive_query', $result, 'post-reading-time' );
}
// Use $result as usual.

A few things to cover here. “expensive_query” is the cache key. It’s basically the key in the associative array. “post-reading-time” is a group. A group is like a namespace for cache keys. Its purpose is to prevent name collisions and group different parts of the Object Cache together. It’s the reason why we didn’t have to prefix “expensive_query” with “pgn_”. But why did I make the group “post-reading-time” and not “pgn”? Well, it’s a good practice to name your group like your plugin slug. This is similar to how the text domain in i18n functions should also be the slug.

The fourth argument to wp_cache_get() is particularly interesting. It’s a variable passed by reference. Its value will be true if the cache key was found, and false if it wasn’t. We later use it to decide if we should perform the expensive operation. In the real world, you’ll often see people use only the key and the group, and then check if $result is false to determine if the value was found. This is incorrect (usually). The reason is that the cached value can be falsy (or even just false).

If you do “if ( ! $result )”, how do you know if the value wasn’t found, or if it was found but was 0? You’re going to do the heavy processing and get 0 again, even though you already had it in the cache (even strict comparison won’t protect you if the cached value can be false). On the other hand, $found will always be either true or false – depending on if the value was found. As a rule of thumb – always use the fourth parameter. Even if you have 100% certainty that the value can never be falsy, it’s just a more robust practice.

There is one huge problem with the default Object Cache – it’s not persistent. Because it’s just an associative array in memory, it gets destroyed after PHP is done processing the request. All of its data has to be regenerated on every request. This means that if you were to do the expensive operation only once, you’d see absolutely no performance benefit from using cache. Thankfully, this is a solved problem.

Persistent Object Cache

Ever heard of Redis or Memcached? These are basically external in-memory associative arrays/databases. You run them as separate processes on your server and connect to them through the network (just like you do with your database; IP 127.0.0.1 if the Redis/Memcached server is running on the same machine as your web server). Because of that, they aren’t dependent on your PHP processes running. This means they don’t get wiped at the end of every request, i.e., they are persistent.

WordPress provides an easy way of replacing the built-in cache system with a persistent one. Before loading this cache API and instantiating $wp_object_cache, WordPress looks for a file called object-cache.php in /wp-contents/. If this “drop-in” file is present, it is loaded and used instead.

That’s the foundation of many plugins, like Redis Object Cache, Object Cache Pro, SQLite Object Cache, and more. All of these plugins create this file when activated. The file contains their own implementation of the WP_Object_Cache class and the procedural helper functions. The most important part is – the signatures of the functions stay the same, so you don’t have to modify your code after installing a plugin like that. You just activate it, configure it, and boom – your site has persistent Object Cache.

Keep in mind that for this to work, you have to have the Redis server (or whatever you’re using) running somewhere in your environment. It’s a program external to your website that the plugin has to connect to to read from and write to. Keep that in mind when choosing your hosting provider – most high quality ones will have it pre-configured for you.

This is great because it provides persistence and sharing between requests. Remember the expensive query you had to run just once for each request? You won’t have to run it at all anymore as long as it’s present in the cache. This really is life-changing, but there is a catch – you have to pay more attention to how you use the cache.

See, persistent cache introduces some problems its more primitive alternative doesn’t have to worry about. First and foremost – you have to be careful about what data you cache. Imagine that you have a WooCommerce store and your code runs something like this:

PHP
wp_cache_set( 'user_billing', $user_data, 'myplugin' );

You just cached the personal billing information of the first user who happened to enter the website when the cache was empty. You will then likely keep using this information with wp_cache_get() and every other user in your store will see this billing info. That’s a data privacy violation of the highest order. You didn’t have to worry about that with non-persistent cache because it was specific to the request, but now that it’s shared, you need to be very careful about what you cache.

When caching data like that, you have to make the key dynamic. Instead of “user_billing”, make it “user_billing_$userid”. You can also make the group dynamic, so instead of “my-plugin”, make it “my-plugin-$userid”. If you do that, you will also be able to flush all cache for the user with one call to wp_cache_flush_group() (if the Object Cache implementation you’re using supports it). Whatever you do, just be consistent (for your own sanity).

Another problem with persistent Object Cache is data staleness. The wp_cache_set() function has an optional fourth argument – $expire (Time To Live – TTL). It expects a number of seconds signifying the time after which the entry should be deleted. The default value is 0, meaning indefinite (the data will attempt to stay in the cache forever).

So, what TTL should you use? This question is as if you came to my place and I asked you where the glasses are – how the hell are you supposed to know? You should know what TTL your data needs based on what data it is. Is it an API response that refreshes every 24 hours? How about 24 hours? Or calculate it dynamically to be invalidated at a given time. Is it something that needs to be relatively fresh? Use your judgment – maybe 5 minutes?

That being said, you should usually not set $expire at all. A much better strategy for cache invalidation is event-based. Just think about it – how long is the cache of user’s billing info valid? Well, it may be 20 seconds, or it may be a year. The problem is that we’re thinking of it in terms of the wrong units – time. In reality, it’s valid until it’s changed. Always try to invalidate the cache when the data is modified. Hook into the action fired when the data is changed and call wp_cache_replace() (or wp_cache_flush_group() if you need to flush a whole group of entries, e.g., all queries for posts when a new post is added).

How WordPress Core Uses Object Cache

The core WordPress code is full of calls to wp_cache_*(). This is why installing a persistent Object Cache plugin speeds up even vanilla WordPress installs. WordPress usually handles cache invalidation pretty well (usually in event-based fashion), but if you’re using persistent cache and are experiencing some weird issues, spending a few minutes reading the core to see how it works probably won’t hurt.

Some of the data WordPress caches includes:

  • Options – get_option() tries to get the data from cache before querying the database.
  • Posts – functions like get_post() try to get the post from the cache before touching the database. This cache is intelligently invalidated when the post is updated.
  • Metadata – get_post_meta() and other metadata functions read from and write to cache.
  • Terms and Taxonomies
  • Comments
  • and more…

As far as I know, full results of WP_Query calls are not cached by default. If you want your queries cached, make sure to handle that yourself (like we did with ‘expensive_query’) in the example above.

3. Transients API

The WordPress Transients API is very similar to Object Cache. As a matter of fact, it uses wp_cache_*() functions if persistent Object Cache is configured. The difference is that if it isn’t configured (which is the default WordPress configuration), the value is stored in the database. This allows for persistent caching without a Redis/Memcached server.

There are 3 main functions provided by this API:

  • get_transient()
  • set_transient()
  • delete_transient()

Every transient has an expiration time (TTL). You set that TTL when calling set_transient(). Alternatively, a transient can be deleted on an event with delete_transient(), just like you can delete an Object Cache entry with wp_cache_delete(). Here’s an example of using the Transients API to cache an expensive remote API call:

PHP
// Start by trying to read the value from the cache.
$response = get_transient( 'pgn_response' );

// If the value isn't there, do the expensive operation and cache it.
if ( ! $response ) {
    $response = wp_remote_get( $url );
    // You should probably do some error handling here.
    set_transient( 'pgn_response', $response,  DAY_IN_SECONDS );
}
// Use $response as usual.

This is literally just the example from the Object Cache section with a few tweaks. You can see that transients don’t utilize a group. We have to prefix our key with “pgn_”. After this code runs (and assuming no valid ‘pgn_response’ transient exists), the ‘pgn_response’ transient will be added and its TTL will be 86400 seconds. 

If a persistent Object Cache plugin is installed and configured, this entry will be stored in the Object Cache (Redis/Memcached). If it’s not, the data (HTTP response) along with the expiration time will be stored in the wp_options table in the database. That’s right, transients are stored in wp_options. As a matter of fact, the Transients API functions use the Options API internally, with calls to functions like get_option(), add_option(), and delete_option() all over the place.

Transients In The Database

Every set_transient() (without persistent Object Cache enabled) adds two options:

  • _transient_$key
  • _transient_timeout_$key

For our pgn_response, it would be _transient_pgn_response and _transient_timeout_pgn_response. The first one is obviously just the value you’re storing. In our case, it’d be the serialized array/WP_Error object returned by wp_remote_get(). The timeout is calculated in set_transient() as time() + $timeout. It’s just a UNIX timestamp of when the entry should be considered expired.

Expired transients are deleted from the database when you try to fetch them with get_transient(). This used to be the main method for purging transients. However, you can probably see why it’s flawed. What if the code calling get_transient() never runs? Like if you delete a plugin after it has set a transient. In such a case, the transient would just stay there forever, littering your wp_options table.

Thankfully, this has changed in WordPress 4.9. This update introduced the delete_expired_transients() function and a WP-Cron event with the same name. This event runs once a day and calls this function. As a side note – make sure you don’t have persistent Object Cache configured if you’re trying to call delete_expired_transients(). I was seriously puzzled as to why this cron event wasn’t working, only to realize that it doesn’t purge the transients from the database if Object Cache is in use (even if they are expired).

Transients vs Object Cache

The Transients API might look like a better Object Cache. Why use wp_cache_*() functions if you can just set and get transients? It will use persistent Object Cache anyway, and will fall back to the database if it’s not present. You can even use delete_transient() to flush an entry on an event. It seems kind of awesome, doesn’t it? Well, it’s complicated.

The first and most important rule you have to keep in mind when writing a public plugin or theme is that you should assume persistent Object Cache is not present. Most WordPress websites that install your plugin won’t have Redis running on the backend. Your transients will end up in the database. This underlying assumption is the most important reason why transients aren’t always the best choice.

Imagine what would happen if WordPress core used set_transient() to cache WP_Post objects for get_post(). On a site with 10,000 posts, you’d have an additional 20,000 options (the value and the timeout). This fact alone would make operations on wp_options slower, which would have a negative performance impact not only on transients, but on all Options API calls on your site.

Another obvious question is “why?”. Why store a cache of the data in the database if the source of truth for this data lives in the same database (just in another table)? It may even take longer to fetch the transient than the data itself because transients require querying for two rows (value and timeout). Using transients for all post objects would actually increase the number of database queries, not reduce it.

That’s exactly why WordPress doesn’t use transients for all these objects like posts, options, terms, or comments. They would bloat up the database and most likely degrade performance. For such data, using the Object Cache makes perfect sense. A read from memory is almost always going to be faster than a query to the database, and even if no persistent cache is used – it doesn’t hurt.

When considering whether you should use the Transients API or Object Cache, ask yourself if reading the data from wp_options will be noticeably faster than just computing it, and if it’s not going to bloat the database. If you’re caching something that’s already in the database – using transients is silly. It’s also irresponsible for you to use the Transients API for data that is dependent on an unbounded number of entities, like one entry for every user or post. A site with 100k users will have to deal with 100k transients.

On the other hand, caching a single HTTP response from a remote API is a perfect use case for a transient. It will be beneficial even if persistent Object Cache is not used and the transient lands in the database. At the end of the day – the performance question is really dependent on context. Is it going to be advantageous to cache the results of a complex mathematical computation in the database, or will the SQL query take longer than just computing it on every request? Run some benchmarks, analyze the trade-offs, and make a conscious decision.

There’s also a question of semantical meaning. The Transients API was designed around the concept of entries expiring after some time. This is not the assumption for Object Cache, which is often used to store entries indefinitely with invalidation on events. You should keep that in mind when choosing the right caching mechanism.

That being said, transients can be used for data stored indefinitely. If you set TTL to 0, the _transient_timeout_$key option will not be created. WordPress core uses this technique to cache the results of site health check and only replace it the next time the health check script runs. But there’s a catch – transients with TTL 0 are autoloaded. Why? I have no idea. Someone just decided to do it this way, and now all your transients without expiration will be loaded on every request, even if they aren’t used.

If your situation really calls for an infinite transient (particularly if its multiple transients), it might be a better idea to set its TTL to some obscenely large value, like 10 * YEAR_IN_SECONDS. This way it won’t be autoloaded. That being said, the Transient API is usually meant to be used with values that expire after a set amount of time, unless sizable benefits can be obtained from caching the value in the database. Just be smart about it.

To sum up – you should use the Transients API for values that take so long to compute that fetching them from the database is likely to always be faster. You should definitely not use the Transients API to cache a large number of entries, such as one or multiple transients for every user, as this may bloat the database on larger sites. For operations that are likely to be similar or faster than querying the wp_options table – use Object Cache. Whatever you do, make sure you analyze and understand the implications of your choice. The right caching solution is dependent on context and should be determined on a case-by-case basis.

4. Full-Page Caching

Full-page caching is the process of caching and serving the response body. Imagine that WordPress had to run its entire request lifecycle for every request, including all hooks, functions, etc., just to render the exact same HTML for every user. That’s insanely wasteful and inefficient. Full-page caching solves that by generating this response only once, storing it, and serving it like a static file. It can make your website hundreds of times faster.

There are 3 different types of full-page caching:

  • PHP-level full-page caching
  • Server-level full-page caching
  • Reverse Proxies & Edge Caching

PHP-Level Full-Page Caching

PHP-level full-page caching is always dependent on a WordPress plugin. This plugin almost always creates a /wp-content/advanced-cache.php file and defines a WP_CACHE constant in wp-config.php. The advanced-cache.php file is a drop-in file, just like object-cache.php that we covered a moment ago.

If this file is present (and WP_CACHE is defined), it will be loaded very early in the request lifecycle. It is included at the very beginning of wp-settings.php, pretty much right after wp-config.php is loaded. This means that it will run way before any theme, plugin, or even most core code (including the database connection).

It is designed that way to circumvent as much PHP processing as possible when a cached version of the page exists. This is the job of this file. It looks for a cache of the requested page. If it is found, it renders it and calls die(). Execution is stopped, WordPress isn’t fully bootstrapped, and a lot of time and processing is saved.

Remember that this drop-in’s logic is specific to the plugin you choose. Many plugins have different ways of storing and finding the cached page. The most typical is storing the cache as an HTML file on disk, but there’s nothing stopping you from storing it in persistent Object Cache. These plugins can usually be configured to fit your needs.

But what if the cache isn’t present? In that case, normal execution continues. But there’s a catch – if the current page can be cached, the plugin will capture the rendered HTML and cache it. This way, only the first user has to go through the lengthy PHP processing, and all other users receive the cached version. The way this process works is an implementation detail, but I can imagine those plugins buffering the output with ob_start().

The biggest advantage of PHP-level caching is that it works on all hosts – independent of the environment. It also theoretically provides a little more dynamism, as PHP runs before the content is returned. Unfortunately, it is the slowest out of the three.

Server-Level Full-Page Caching

Another strategy utilizes the web server’s rewrite rules. This also requires a WordPress plugin. It basically works like PHP-level caching, except it usually doesn’t use the advanced-cache.php drop-in. Instead, it generates .htaccess rewrite rules (or other for different web servers) that make it so that index.php is never hit if the cached file exists.

As you can imagine, this is even more performant than PHP-level caching. The WordPress request lifecycle never even begins. Your .html cache file is served to the user the exact same way a static image is. No PHP processing happens, which can be both a blessing and a curse, depending on your requirements.

Many plugins support both server-level and PHP-level configuration. One such example is W3 Total Cache, which has options for both. Some don’t even bother with PHP-level caching. WP Fastest Cache seems to be one of them.

The main advantage of this strategy is speed. PHP is never run, which makes the response faster. That being said, a plugin like that might not work on all hosts. Rewrite rules are specific to the web server. If a plugin only modifies .htaccess and your website is run with Nginx with no Apache, the rewriting won’t work. This can sometimes be problematic, especially on shared hostings or other environments that you don’t control.

Reverse Proxies & Generic Server-Level Full-Page Caching

This type of full-page caching is the only one on my list that doesn’t require a WordPress plugin. A reverse proxy is a piece of software that sits between the user and your web server. One popular example is Varnish. Its job is to intercept requests from the user and serve the cached page if it exists. The request never even reaches your web server. Content Delivery Networks (CDNs) would also qualify under this section.

Another side of this coin are caching modules built into web servers. Pretty much all web servers have them, i.e., Apache, Nginx, LiteSpeed, etc. If configured, they can cache the responses from PHP and serve them without ever having to consult the rewrite rules.

These solutions are completely detached from WordPress. It has its benefits – they are usually faster and more powerful. That being said, the downsides are stark. First of all – because they are WordPress agnostic, they aren’t preconfigured to work well with WordPress. This means that in most likelihood, you will have to manually configure them to not cache pages for logged in users or other sensitive URLs – configuration that is almost always natively supported in WordPress caching plugins.

Another big problem is purging the cache. Caching plugins can hook into different actions and update the cache, e.g., on post edit, when a new post is published, etc. When using a reverse proxy, it has no idea about any of that. You have to make sure that the cache is purged when you need to purge it. In reality, this is usually achieved by installing a plugin that forces the purge on certain events (Proxy Cache Purge is often used for Varnish).

Solutions like that usually require more advanced system configuration and knowledge. If you mess it up, you’re risking quirky behavior (website not updating on edits) and data leaks (personal information getting cached). One exception and an interesting case study is LiteSpeed Cache.

The LSCache plugin is a WordPress plugin developed and maintained by the official LiteSpeed team. It is basically a connection layer between your website and the LiteSpeed web server. It takes care of all the WordPress specific things, while the actual cache is stored and served by the web server. It doesn’t cache the files itself – it intelligently communicates with the web server to manage its cache. The benefit is blazing fast performance with built-in WordPress integration. I can recommend it if you’re using LiteSpeed as your web server.

Which Full-Page Caching Strategy To Use

All of these three caching strategies have their pros and cons. PHP-level caching is the most versatile of them all. It usually doesn’t require any configuration and just works out of the box on all hosts. The price you pay for that is worse performance. Avoid this strategy if you can.

Server-level caching is usually the perfect compromise. It’s faster than PHP-level because it completely circumvents any PHP processing by serving the static HTML files with custom rewrite rules. That being said, it’s not without downsides. The one you’re most likely to encounter is problems with rewrite configuration. This is especially true if you’re using a web server other than Apache, like Nginx as the sole server.

Reverse proxies and generic web server cache can be very powerful in certain contexts. Varnish is probably more capable and robust than any WordPress plugin. CDNs can allow you to introduce edge caching for truly blazing-fast performance. The price you pay is complexity.

Reverse proxies should absolutely not be used by hobbyist bloggers with 5 blog posts. They require much more technical configuration and testing in order to make sure everything works as expected. For most WordPress websites, it’s like using a sledgehammer to crack a nut – a good server-level caching plugin will definitely suffice. That being said, if you’re qualified and are willing to spend more time on configuration, they can make a very big difference (especially CDNs like Cloudflare). The exception is obviously LiteSpeed Cache which I already discussed.

Caching Rules

Full-page caching for some static websites can be set up by just installing a plugin and forgetting about it. Unfortunately, most websites aren’t like that. Making sure the right pages do and don’t get cached is crucial for making your site work as expected. I’m talking specifically about establishing correct caching rules. Caching rules are exactly what you think they are – they specify which pages should and should not be cached.

Query Strings

One important area is query strings. Which query parameters should be cached, and which should be ignored? Should URLs with query strings be cached at all? These aren’t simple questions.

Let’s take a request to /blog/?_ga=123googleanalyticsidentifier. Should it serve the page that was cached for /blog/? Yes, because the query parameter doesn’t change the rendered HTML. On the other hand, let’s take /blog/?search=post. Should this be served the blog cache? Absolutely not. The rendered HTML will be completely different (assuming it’s a search result for “post”).

In that case, you have to configure your caching solution to ignore “_ga”. Some caching plugins will automatically create separate cached versions for every not-ignored query parameter (i.e., our “search”). That being said, many plugins default to not caching query strings at all. A request to /blog/?search would always run the full WordPress PHP execution.

There’s a good reason for that. Query strings are usually used for rendering dynamic content, exactly like the search functionality. Unless you know what you’re doing, it is safer not to cache those pages. The number of cache entries can also explode. /page/?foo=bar would create an entry, and so would /page/?foo=baz. If you store cached pages indefinitely or with a very high TTL, a malicious user (or even bot traffic) could fill your disk space with cache files.

If you know what you’re doing and what query parameters your website expects, you may see a significant performance boost from intelligently caching and purging those. This part is completely yours to configure. No one knows what pages and parameters are present on your website better than you do. Make sure to set the correct rules and test it ruthlessly (including purging on different actions). Just keep in mind the risks that go along with caching query strings.

Cookies

Cookies are similar to query strings in that their value may change the rendered content. Take a language cookie as an example. A multilingual plugin can set a cookie to “remember” the language of the website the user selected. In such a case, the presence of the cookie results in different dynamic content being rendered. Another example could be a multicurrency WooCommerce plugin.

Pretty much all of the same tips and caveats apply to cookies as to query strings. Most caching solutions allow you to completely skip cache (i.e., serve uncached content) when a certain cookie is present. This is how these plugins skip cache for logged in users (which is the default behavior for pretty much all plugins). Some of them allow you to create different caches for different values of the cookie. The same problems as when caching query strings apply here (a large number of files, configuring the right cookies, purging, etc.).

Pages & Other

Naturally, pretty much all caching solutions allow you to specify URLs that shouldn’t be cached (usually allowing wildcards like /blog/* to not cache any blog posts). This is useful when you have a few pages that are very dynamic and you want to always serve them uncached.

There are also a plethora of other possible cache rules to configure, such as caching by user agents, post categories, or even custom fields. Different plugins allow for different customization. Whatever you do, make sure to be smart about it. Make sure you understand what it means that a given page will or will not be cached and test your website in incognito. You should be well equipped by now to make knowledgeable decisions.

Dynamic Content

We’ve actually already discussed caching with dynamic content briefly when talking about nonces in the security chapter. Let’s start by covering logged-in users, as this is usually the biggest pain point. As I already said, the typical configuration is to completely skip cache when the wordpress_logged_in_* cookie is present. This solves the problem, but is also the most inefficient.

Another option that some plugins provide is a separate cache for each user. This solves the problem of personalization. If the user’s name is shown on the page, it will also be present in their cached static file. While this approach might seem attractive, it’s very problematic. First of all, there’s usually little to no benefit from that. Logged-in users won’t typically visit the same page multiple times. Even if you cache it, they will only request it one time – and the cache won’t exist yet.

Secondly, if you have many users, the amount of cached pages can grow quite fast. This is also the case with query strings and cookies-varied cache (as all of them are basically just dynamic pages), but caching logged-in responses only adds fuel to the fire. Also, remember that the data of the user is not available until way after advanced-cache.php was run. This means that logged-in cache will likely be less efficient than even typical PHP-level caching (unless the plugin handles that with proprietary logic).

Overall, the general consensus, not only in the WordPress community, but also in the broader web development community, is to skip full-page caching for logged-in users. A logged-in experience is, by its nature, dynamic. You will be much better off trying to optimize your code by utilizing Object Cache and transients where applicable (and by getting rid of heavy, inefficient plugins).

That being said, there is technically a legitimate way to still utilize (almost) full-page caching for logged-in users and pages with dynamic data. It is only really realistic if you have full control over the entirety of the website, particularly over the theme (preferably custom).

I’m talking about making your PHP-rendered HTML not contain any personal/dynamic data. Instead, it would contain placeholders, and the actual content would then be loaded using AJAX on the frontend or with Edge Side Includes if your caching solution supports it (LiteSpeed Cache does!). This approach is sometimes referred to as hole punching.

With an architecture like that, you could serve the same static HTML file to all of your visitors. Although possible, you should ask yourself if it’s realistic. It will probably not be compatible with most plugins and will require a lot of custom code. I could see it being used for certain high budget projects, but 99.9% of WordPress websites will never want to do this.

5. Browser Caching

The very last layer in caching is the user’s browser. Most browsers will cache pages by default. Browser caching is controlled with response headers sent by the server. There are a few different headers that have been used for different purposes over the years, but the most important modern header is Cache-Control.

Cache-Control allows you to specify directives like max-age, no-cache, private, public, stale-while-revalidate, stale-if-error, etc. These directives change the behavior of the clients. As a matter of fact, they aren’t only used by browsers. They are usually the way the web server communicates with reverse proxy caching solutions, such as Varnish or Content Delivery Networks.

This topic is way outside the scope of this guide. Besides, as a WordPress developer, it’s rather unlikely that you will ever have to interact with these headers directly. Go read a little about Cache-Control to have this knowledge in the back of your head. It may come in handy when you encounter some obscure problem that should not have the right to exist (which is usually the type of problems caused by cache).