Table of contents of the article:
When a WordPress or WooCommerce site grows in traffic, complexity, and number of concurrent requests, there inevitably comes a point where simply "optimizing PHP" isn't enough. You can improve SQL queries, reduce unnecessary plugins, use object cache, update PHP-FPM, configure OPcache, and lighten the theme. All useful, of course. But if every visit continues to generate a new WordPress run—with core bootstrapping, plugin loading, database queries, theme rendering, and HTML output—the architectural limitation remains.
An WebCache It was created to solve precisely this problem: to prevent the application backend from having to regenerate the same response when that response is the same for many users. In other words, if a site's public homepage is identical for thousands of anonymous visitors, it makes no sense to run PHP and query MySQL thousands of times. It's much more efficient to generate that page once, cache it, and serve it directly from the cache until it expires or is invalidated.
This approach isn't a marginal trick, but a shift in perspective. Developers no longer need to think simply in terms of "this function prints data," but must ask themselves: Can this data be shared between multiple users? If so, it can be contained in cacheable HTML. If not, it must be handled differently: via JavaScript, AJAX/REST calls, Edge Side Includes, non-cached endpoints, controlled cookies, or separate application logic.
HTTP caching is one of the most powerful tools for reducing TTFB, CPU load, database queries, and backend saturation. However, it's also one of the easiest tools to break with a single incorrect line of code. A randomly set cookie, a header, Cache-Control: no-store on a public page, a PHP session started anywhere, a per-user server-side customization, or a poorly managed mobile/desktop variant can turn a performant platform into a system that constantly bypasses the cache.
This guide is intended for the developer working on WordPress and WooCommerce in the presence of Varnish cache or a generic web cache, including configurations based on NGINX reverse proxy, CDN or hosting-level HTTP cache.
What is a Web Cache?
A web cache is an intermediate component that temporarily stores an HTTP response and reuses it for subsequent compatible requests. It can be located in the user's browser, in a CDN, in a reverse proxy in front of the web server, or directly on the web server via modules such as proxy_cache o fastcgi_cache of NGINX.
In the hosting and systems context, when we talk about Web Cache we often mean a server-side shared cache, that is, a cache used by multiple users and placed in front of the application. The typical flow is this:
- the browser requests a page;
- the request hits the cache;
- if the cache has a valid copy of the response, it serves it immediately;
- if it does not have it or if the copy has expired, forward the request to the backend;
- The backend generates the response and the cache decides whether to keep it.
In a classic configuration with Varnish, the path can be:
Browser → Varnish → NGINX/Apache → PHP-FPM → WordPress → MySQL
With a cache hit, however, the path stops much earlier:
Browser → Varnish → risposta HTML già pronta
This difference is huge. A response served by Varnish can completely bypass PHP execution, WordPress loading, and database connection. Therefore, on high-traffic public pages, a well-configured HTTP cache can make the difference between a stable site and one that crashes under load.
Why Varnish Cache is an Enterprise Choice
Varnish cache It's one of the most widely used HTTP reverse proxies when the goal is to serve cached content with high performance and very precise control logic. Its strength lies not only in speed, but also in the ability to describe cache behavior via VCL, Varnish's configuration language.
With Varnish you can decide, for example, which URLs should never be cached, which cookies should be ignored, which headers should be cached, how long a response can remain valid, when to serve stale content, how to handle a purge and how to differentiate between desktop/mobile variants, language, currency or country.
In enterprise environments, this programmability is essential. A WooCommerce site doesn't have the same needs as a magazine, a publishing portal doesn't have the same rules as a membership platform, and an advertising landing page doesn't have the same degree of dynamism as a customer area. An effective cache can't be an on/off switch: it must be a consistent application policy.
Varnish is particularly suitable when you want to separate the task of serving public content from the task of generating dynamic content. The WordPress backend remains responsible for content production, while Varnish becomes the fast, stable, and controllable distribution layer.
The contract between application and cache
A web cache shouldn't be seen as an external, mysterious element. It should be considered part of the application architecture. The backend communicates with the cache via URLs, HTTP methods, headers, cookies, and status codes. The cache, in turn, decides whether a response can be stored, for how long, and for which requests it can be reused.
For a developer the key concept is this: Each page must clearly state whether it is public, private, shareable, variable, or non-cacheable..
A public page, such as a blog post or product category without user customizations, can use similar headers:
Cache-Control: public, s-maxage=3600, max-age=300
This means: the shared cache can store the page for one hour, while the browser can store it for five minutes. Conversely, a private page, such as checkout, the account area, or a personal dashboard, should clearly state that it is not suitable for shared caching:
Cache-Control: private, no-store
The problem arises when the application doesn't distinguish. If all pages send cookies, if all open sessions, if all include personal data, the cache can no longer distinguish between public and private. At that point, for security reasons, it will tend to bypass or not store anything.
What happens to PHP on a cached page?
One of the most common mistakes in WordPress development with Varnish is thinking that PHP code will be executed on every visit. This isn't the case. If a page is served from cache, PHP does not runThe visitor receives the previously generated HTML.
This means that any PHP logic inserted into the template, theme, or shortcode is "photographed" when the page is generated. If the first anonymous visitor generates a page with a certain output, that output can also be served to subsequent visitors until it expires or is purged.
This is perfect for public content like titles, text, images, standard product prices, descriptions, breadcrumbs, schema markup, static menus, or editorial blocks. However, it's dangerous for anything that changes based on the user, session, geolocation, cart, login, personal preferences, or browsing history.
Bad example in a WordPress theme:
<?php
if ( is_user_logged_in() ) {
echo 'Ciao ' . esc_html( wp_get_current_user()->display_name );
} else {
echo 'Accedi al tuo account';
}
?>
If this logic is printed within a cacheable page, an ambiguous situation arises. Either the cache is bypassed for all users with login cookies, reducing the system's effectiveness, or an incorrect version of the page is stored. The problem isn't the function. is_user_logged_in() in itself, but the fact that it is used to produce custom HTML inside a response that should be public.
The correct solution is to separate the public HTML from the private customization. The template can print a neutral container:
<div id="user-area" data-endpoint="/wp-json/ms/v1/user-bar">
<a href="/my-account/">Accedi al tuo account</a>
</div>
Then JavaScript can query a non-cached or privately cached endpoint and update only that fragment:
fetch('/wp-json/ms/v1/user-bar', {
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
})
.then(response => response.ok ? response.json() : null)
.then(data => {
if (!data || !data.logged_in) return;
```
const box = document.getElementById('user-area');
if (box) {
box.innerHTML = '<a href="/my-account/">Ciao ' + data.name + '</a>';
}
```
});
This way, the main page remains cacheable, while the personal details are loaded separately only when needed.
The Golden Rule: Don't put private data in cacheable HTML
When using Web Cache, the most important rule is simple: anything that is different for a single user must not be generated server-side inside a cached public page.
This includes:
- name of the logged in user;
- number of products in the cart;
- customized prices for customer groups;
- individual discounts;
- session-based messages;
- personal wishlist;
- recently viewed products;
- private notifications;
- content based on non-normalized cookies;
- content based on uncontrolled IP or GeoIP.
Caching works best when the HTML is public, deterministic, and shareable. If a public page contains five small dynamic elements, that doesn't mean you should forgo caching the entire page. It just means you should design those five elements as separate dynamic fragments.
Cookies: The most common reason for cache bypasses
Cookies are probably the main point of friction between application development and HTTP caching. Many frameworks, CMSs, marketing plugins, tracking systems, privacy banners, A/B testing, and eCommerce components set cookies even when they aren't necessary. From a caching perspective, however, a cookie in the request can mean, "Warning, this response may be personalized."
In Varnish, a conservative configuration tends to not cache or serve cached requests that have relevant cookies. This behavior makes sense: if the cookie contains a user session, a shopping cart, or an authentication token, serving a shared page might not be possible. The problem is that cookies present in the request often have no impact on the HTML content.
Examples of cookies that normally should not invalidate the public page cache:
- analytics cookies;
- advertising cookies;
- consent cookies already managed on the browser side;
- graphic preference cookies not used by the backend;
- UTM campaign cookies saved for tracking only;
- Technical cookies not linked to HTML rendering.
Examples of cookies that may require bypass or variation:
wordpress_logged_in_*;wp_woocommerce_session_*;woocommerce_items_in_cart;woocommerce_cart_hash;- membership cookies;
- currency cookie if the price changes server-side;
- language cookie if the language is not already in the URL;
- Price group or customer price list cookie.
Developers should therefore avoid using cookies as a generic repository for any information. Each cookie should have a clear purpose: does the backend really need it to generate different HTML? If not, it probably shouldn't be included in the caching decision.
How to Misuse Cookies in WordPress and WooCommerce
A common mistake is to set a PHP cookie on all pages of the site, perhaps in the file functions.php, in a custom plugin or in a mu-plugin:
add_action('init', function () {
setcookie('visited_site', '1', time() + 3600, COOKIEPATH, COOKIE_DOMAIN);
});
This code seems harmless, but it tells the cache that each response may set a cookie. If the backend returns a header Set-CookieMany configurations don't store the response to avoid sharing cookies between users. The result is that even perfectly public pages can become uncacheable.
Another common mistake:
add_action('init', function () {
if (!session_id()) {
session_start();
}
});
Starting a PHP session across WordPress is almost always a bad idea when full-page caching is present. The session introduces individual state, sets cookies, and makes the page less shareable. In many cases, the session is used only to display a message, save a temporary preference, or remember a value that could be handled with JavaScript, localStorage, server-side transients, or a dedicated endpoint.
In WooCommerce, cart cookies are necessary, but they shouldn't be used as a site-wide bypass. A correct configuration should exclude checkout, cart, my-account, and cart actions, but should allow caching of product pages, categories, and editorial pages when no privacy settings are applied.
WooCommerce: What Must Remain Dynamic
WooCommerce introduces special requirements because an eCommerce site has both public and highly personal pages. The product page, in many cases, is public. The cart, checkout, and account area, however, are personal and must be excluded from the full page cache.
Pages that normally should not be cached are:
- Cart, because it displays content related to the customer's session;
- Checkout, because it contains data, shipping methods, payment, nonce and order status;
- My Account, because it is a private area;
- WooCommerce endpoint, as you call
wc-ajaxor operational APIs; - URLs with actions like
?add-to-cart=, coupons, product removal and quantity update.
On the contrary, they can often be cached:
- public homepage;
- product category pages;
- product sheets for anonymous users;
- blog posts;
- landing page;
- static CMS pages;
- static assets and images.
But be careful about price, stock, and promotions. If the price changes based on user, country, currency, role, quantity, or B2B price list, the product page is no longer a simple, single, public page. You need to decide whether to differentiate the cache, load the price via JavaScript, use dedicated endpoints, or exclude only truly dynamic conditions from the cache.
Sample VCL for WordPress and WooCommerce
A real VCL must always be adapted to the infrastructure, the Varnish version, the header configuration, and the site's needs. However, this example demonstrates the general principle: pass truly dynamic areas, clean unnecessary cookies, and leave public pages cacheable.
sub vcl_recv {
# Non cacheare metodi non sicuri o non idempotenti
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
```
# WordPress admin e login sempre pass
if (req.url ~ "^/wp-admin" || req.url ~ "^/wp-login.php") {
return (pass);
}
# WooCommerce: aree dinamiche
if (req.url ~ "^/(cart|checkout|my-account)(/|$)") {
return (pass);
}
# WooCommerce: azioni e AJAX operativi
if (req.url ~ "(\?add-to-cart=|wc-ajax=|remove_item=|apply_coupon=)") {
return (pass);
}
# Utenti WordPress loggati: pass
if (req.http.Cookie ~ "wordpress_logged_in_") {
return (pass);
}
# Carrello WooCommerce presente: pass o gestione dedicata
if (req.http.Cookie ~ "woocommerce_items_in_cart=1") {
return (pass);
}
# Rimuovi cookie che non influenzano l'HTML pubblico
if (req.http.Cookie) {
set req.http.Cookie = regsuball(req.http.Cookie, "(^|; )_ga=[^;]*", "");
set req.http.Cookie = regsuball(req.http.Cookie, "(^|; )_gid=[^;]*", "");
set req.http.Cookie = regsuball(req.http.Cookie, "(^|; )_fbp=[^;]*", "");
set req.http.Cookie = regsuball(req.http.Cookie, "(^|; )cookie_notice_accepted=[^;]*", "");
# Se dopo la pulizia il cookie è vuoto, rimuovilo
if (req.http.Cookie ~ "^\s*$") {
unset req.http.Cookie;
}
}
```
}
sub vcl_backend_response {
# Non cacheare risposte che impostano cookie su aree dinamiche
if (bereq.url ~ "^/(cart|checkout|my-account)(/|$)") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
```
# Per pagine pubbliche, se il backend imposta cookie inutili, valutarne la rimozione
if (bereq.method == "GET" && bereq.url !~ "^/(wp-admin|wp-login.php|cart|checkout|my-account)") {
unset beresp.http.Set-Cookie;
}
# TTL di esempio per contenuti pubblici
if (beresp.status == 200) {
set beresp.ttl = 1h;
set beresp.grace = 10m;
}
```
}
This isn't a file to blindly copy into production. It's a conceptual framework. On a real site, you need to verify actual cookies, installed plugins, member area, multilingual, currency, GeoIP, cache purge, backend headers, and checkout flows.
NGINX as a Web Cache: A Conceptual Example
When not using Varnish, NGINX can still perform a caching role via proxy_cache o fastcgi_cacheThe logic remains similar: define a cache key, decide when to bypass, decide when not to save the response, and add diagnostic headers.
Simplified example with reverse proxy:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=WORDPRESS:100m inactive=60m max_size=5g;
map $http_cookie $skip_cache_cookie {
default 0;
~wordpress_logged_in_ 1;
~woocommerce_items_in_cart=1 1;
~wp_woocommerce_session_ 1;
}
map $request_uri $skip_cache_uri {
default 0;
~^/wp-admin 1;
~^/wp-login.php 1;
~^/(cart|checkout|my-account) 1;
~add-to-cart= 1;
~wc-ajax= 1;
}
server {
location / {
proxy_pass http://backend_wordpress;
```
proxy_cache WORDPRESS;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_bypass $skip_cache_cookie $skip_cache_uri $http_authorization;
proxy_no_cache $skip_cache_cookie $skip_cache_uri $http_authorization;
proxy_cache_valid 200 301 302 60m;
proxy_cache_valid 404 5m;
add_header X-Cache-Status $upstream_cache_status always;
}
```
}
The distinction between proxy_cache_bypass e proxy_no_cache This is important. The first tells NGINX not to fetch the response from the cache under certain conditions. The second tells NGINX not to save the response obtained from the backend. In many configurations, they should be used together, because a private request should neither read a shared copy nor populate the cache with a private response.
Desktop and mobile: the problem of different layouts
Many modern WordPress sites use responsive CSS and therefore serve the same HTML to both desktop and mobile. This is the best-case scenario for caching: one URL, one HTML response, a single cached object. The layout changes in the browser via CSS media queries, not on the server.
The problem arises when the backend generates different HTML based on the user agent. For example:
<?php if ( wp_is_mobile() ) : ?> ``` <div class="mobile-menu">Menu mobile</div> ``` <?php else : ?> ``` <div class="desktop-menu">Menu desktop</div> ``` <?php endif; ?>
If this logic is in a cacheable page, the first device to generate the cache can determine the HTML served to others. If the cache stores the mobile version, desktop users may also receive mobile markup. If it stores the desktop version, mobile users may receive desktop markup.
There are three possible strategies.
The first, and generally preferable, is do not change the HTML. Generate markup that's compatible with both devices and let CSS adapt the interface.
The second is to create an explicit cache variant. In Varnish, you can normalize the User-Agent in a synthetic header, for example X-UA-Device, with limited values such as desktop e mobileThen include that header in the variation logic. The important thing is not to use the entire User-Agent directly as a cache key, because that would produce thousands of nearly useless variations.
sub vcl_recv {
if (req.http.User-Agent ~ "(?i)mobile|android|iphone") {
set req.http.X-UA-Device = "mobile";
} else {
set req.http.X-UA-Device = "desktop";
}
}
sub vcl_hash {
hash_data(req.http.X-UA-Device);
}
The third strategy is to use separate URLs, such as /m/ or specific parameters, but it is rarely advisable for standard WordPress sites today, because it complicates SEO, canonicals, redirects, analytics, and maintenance.
In general, the developer should avoid wp_is_mobile() To decide on critical content within cached pages. It may make sense for micro-optimizations, but if it produces different markup, it must be accompanied by a clear cache variation strategy.
The GeoIP problem
GeoIP is another sensitive issue. Many sites want to display different content based on country: currency, banners, product availability, legal notices, VAT, shipping, language, or local promotions. The problem is that the IP address is variable and not always stable. Furthermore, if the cache is not trained correctly, you risk serving an Italian user a page generated for a German user, or creating a cache variant for each IP, thus destroying the cache's efficiency.
The rule is: never change the cache directly per user IP, except in very specific and controlled cases. Instead, the data should be normalized. For example, you can transform the IP address into a country code and use only that to differentiate the cache:
IT → versione Italia DE → versione Germania FR → versione Francia OTHER → versione generica
Here too, however, one must ask whether it's really necessary to generate different HTML server-side. In many cases, it's better to serve a common public page and use JavaScript to display a local message, suggest a currency exchange, or suggest a language version. If the localized content isn't critical for SEO or compliance, loading it browser-side is often safer for caching.
WooCommerce makes the theme even more sensitive. If the price changes by country, currency, or tax regime, the product page is no longer universally shareable. The alternatives are:
- separate cache for country or normalized currency;
- price loaded via dynamic endpoint;
- public page with standard price and custom calculation only in the cart;
- bypass for truly customized markets or price lists;
- multi-site or multi-store architecture when the differences are structural.
The choice depends on the business model. A blog with a localized banner can use JavaScript. An e-commerce site with VAT, shipping, and different pricing must design the cache along with the tax and business logic.
Cache Hashing in Varnish: How to Create Multiple Controlled Versions of the Same Page
To really understand how Varnish can serve multiple versions of the same page without confusing them, we need to introduce the concept of cache hashingWhen Varnish caches a response, it doesn't just store it “by URL” in a generic way. Internally, it builds a cache key, that is, an identification key used to find the cached object in subsequent requests.
In practical terms, the cache key answers a very specific question: Can this request use the same cached object as a previous request? If the answer is yes, Varnish can serve a HIT. If, however, any relevant elements change, Varnish must look for another object or go to the backend and generate a new variant.
By default, Varnish builds the hash using building blocks like req.url e req.http.hostThis means that, normally, these two requests end up on different cache objects:
https://www.example.com/prodotto/maglia/ https://www.example.com/categoria/t-shirt/
Similarly, if the same Varnish serves multiple domains, the host also enters into the separation logic:
[www.example.com/prodotto/maglia/](http://www.example.com/prodotto/maglia/) shop.example.com/prodotto/maglia/
Even if the path is identical, the different domain must produce a different cache. It would be dangerous to serve content generated for one host on another host.
The interesting point is that Varnish allows you to extend this logic via the subroutine vcl_hashHere you can decide which elements should contribute to the cache key. In other words, you can tell Varnish: "This page doesn't just change by URL and host, but also by device, language, country, currency, or other normalized application state."
The cache key doesn't have to contain everything: it should only contain what actually changes the HTML.
One of the most common mistakes is thinking that, for security reasons, it's a good idea to include a lot of data in the cache key: cookies, full user agent, IP address, various headers, tracking parameters, and user preferences. In reality, this is the opposite of the correct approach.
An effective cache key must include only the elements that actually modify the HTML responseEverything else generates unnecessary fragmentation.
If a WooCommerce product page is identical for all anonymous users, it makes no sense to create a different version for each analytics cookie. If the content doesn't change based on _ga, _fbp, gclid o utm_sourceThese elements must not be included in the hash. Otherwise, Varnish might store many identical copies of the same page, drastically reducing the hit ratio.
The principle to follow is this:
Change the cache only when the content served changes, not when simply the context of the request changes.
How vcl_hash works
The subroutine vcl_hash allows you to add data to the lookup key via hash_data()A simplified scheme could be this:
sub vcl_hash {
hash_data(req.url);
```
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
return (lookup);
```
}
This is the basic logic: URLs plus hosts. However, in a real WordPress or WooCommerce project, there may be cases where the same URL needs to have multiple cached variations. The important thing is that these variations are few, predictable, and controlled.
For example, if your site generates different HTML for desktop and mobile, you could add a normalized value like X-UA-Device:
sub vcl_recv {
if (req.http.User-Agent ~ "(?i)mobile|android|iphone") {
set req.http.X-UA-Device = "mobile";
} else {
set req.http.X-UA-Device = "desktop";
}
}
sub vcl_hash {
hash_data(req.url);
```
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
hash_data(req.http.X-UA-Device);
return (lookup);
```
}
In this case Varnish will be able to keep two versions of the same URL:
/prodotto/maglia/ + desktop /prodotto/maglia/ + mobile
This avoids the classic problem where the first mobile visitor generates a cached page that is then also served to desktop users, or vice versa.
Normalize before hashing
The really important part is not just adding data to the hash, but normalize them firstNever insert highly variable values directly into the hash, such as the entire User-Agent, the full IP, or the entire Cookie header.
A User Agent can contain hundreds or thousands of different combinations. If used directly as part of the cache key, the same page could have a huge number of cached copies:
/prodotto/maglia/ + Chrome iPhone versione X /prodotto/maglia/ + Chrome iPhone versione Y /prodotto/maglia/ + Safari iPhone versione Z /prodotto/maglia/ + Chrome Android modello A /prodotto/maglia/ + Chrome Android modello B /prodotto/maglia/ + Firefox Desktop versione N
Most of these versions would be useless. If the HTML only changes between mobile and desktop, the cache key only needs to distinguish between mobile e desktop. Not among all the browsers in the world.
The same goes for GeoIP. You shouldn't use the visitor's IP address as part of the hash. You should convert it into a more stable classification, for example:
IT DE FR US OTHER
Only after this normalization can the value be used to create cached variants.
Example: Different cache for each GeoIP country
Let's say a WooCommerce store displays different messages based on the visitor's country. For Italy, it displays "Free shipping on orders over €99," for Germany, it displays a different message, and for all other countries, it displays a generic message.
In this case, it doesn't make sense to create a cache for each IP. It makes sense to create three variants:
country=IT country=DE country=OTHER
The VCL could be conceptually similar to this:
sub vcl_recv {
# Esempio: l'header X-Country viene impostato da un load balancer,
# da una CDN o da una logica GeoIP precedente a Varnish.
```
if (req.http.X-Country == "IT") {
set req.http.X-Cache-Country = "IT";
} elseif (req.http.X-Country == "DE") {
set req.http.X-Cache-Country = "DE";
} else {
set req.http.X-Cache-Country = "OTHER";
}
```
}
sub vcl_hash {
hash_data(req.url);
```
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
hash_data(req.http.X-Cache-Country);
return (lookup);
```
}
With this configuration, the same URL can have multiple cached objects, but always in a controlled way:
/prodotto/maglia/ + IT /prodotto/maglia/ + DE /prodotto/maglia/ + OTHER
This approach is very different from completely bypassing the cache. Bypassing says, "I don't use the cache for this case." Hash variation, on the other hand, says, "I use the cache, but I distinguish between versions that are truly different."
Example: Different cache per language
In multilingual WordPress, you need to understand where the language information lives. If the language is in the URL, for example:
/it/prodotto/maglia/ /en/product/t-shirt/ /de/produkt/t-shirt/
then often there is no need to add anything else to the hash, because req.url It's already different. Varnish sees different URLs and stores different objects.
The problem arises when the language depends on cookies or headers, for example:
/prodotto/maglia/ + cookie lingua=it /prodotto/maglia/ + cookie lingua=en
In this scenario, you need to decide whether the language should be included in the key cache. However, you don't need to hash the entire cookie. You just need to extract the relevant value and transform it into a normalized header:
sub vcl_recv {
if (req.http.Cookie ~ "site_lang=it") {
set req.http.X-Cache-Lang = "it";
} elseif (req.http.Cookie ~ "site_lang=en") {
set req.http.X-Cache-Lang = "en";
} elseif (req.http.Cookie ~ "site_lang=de") {
set req.http.X-Cache-Lang = "de";
} else {
set req.http.X-Cache-Lang = "default";
}
}
sub vcl_hash {
hash_data(req.url);
```
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
hash_data(req.http.X-Cache-Lang);
return (lookup);
```
}
In a well-designed project, however, for SEO and architectural clarity reasons, it's often preferable to have the language in the URL. This reduces ambiguity and simplifies canonicals, sitemaps, hreflang, and caching behavior.
WooCommerce Example: Different Currency
WooCommerce can become complex when prices and currencies change based on country, language, or a user preference. If the same product listing displays prices in euros, dollars, or pounds, it's not possible to serve the same HTML cache to everyone.
One solution is to create a cache variant per currency:
sub vcl_recv {
if (req.http.Cookie ~ "currency=EUR") {
set req.http.X-Cache-Currency = "EUR";
} elseif (req.http.Cookie ~ "currency=USD") {
set req.http.X-Cache-Currency = "USD";
} elseif (req.http.Cookie ~ "currency=GBP") {
set req.http.X-Cache-Currency = "GBP";
} else {
set req.http.X-Cache-Currency = "EUR";
}
}
sub vcl_hash {
hash_data(req.url);
```
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
hash_data(req.http.X-Cache-Currency);
return (lookup);
```
}
So the product page can exist in multiple versions:
/prodotto/maglia/ + EUR /prodotto/maglia/ + USD /prodotto/maglia/ + GBP
But be careful: each new dimension added to the hash multiplies the number of variations. If a page varies by device, country, and currency, the total number of items can grow rapidly.
For example:
2 dispositivi x 5 paesi x 3 valute = 30 varianti per URL
If the catalog has 10.000 products, the potential number of cached objects becomes very high. This isn't always a problem, but it must be designed consciously. An enterprise cache shouldn't simply "cache everything": it should cache what produces a real benefit.
Cache variation and WooCommerce cookies
With WooCommerce, a distinction must be made between cookies that indicate a private state and cookies that indicate a public variant.
A cookie like woocommerce_items_in_cart=1 Indicates that the user has products in their cart. In many cases, it's not worth creating a public variant of the product page for this state, because the cart is personal. The safest option is to bypass the full page cache for certain requests or, better yet, continue serving the cached public page and updating the mini-cart via JavaScript.
The case of a cookie that indicates a currency or language is different if that currency or language produces the same HTML for a group of users. In that case, the cookie does not represent a specific user, but a shareable variant. It can then be mined, normalized, and used in the hash.
The distinction is fundamental:
- personal session cookies: normally does not need to generate a shared public cache;
- shareable preference cookie: can generate a variant, if the content is the same for all users with that preference;
- tracking cookies: must not affect the hash and should be ignored by the cache;
- authentication cookies: typically leads to bypass or very controlled application management.
When to Use Hash Variations and When to Use JavaScript
Not everything that changes needs to become a cache variant. Sometimes it's better to use JavaScript.
Caching variation is recommended when different content is important for the first HTML response, SEO, initial rendering, or commercial consistency. For example:
- page in a different language;
- price in a different currency already indexed or visible in the markup;
- truly different server-side layout for mobile and desktop;
- mandatory legal messages by country;
- different catalog for each market.
JavaScript is often preferable for small, personal, or non-essential elements on first rendering:
- number of products in the cart;
- name of the logged in user;
- wishlist;
- promotional banner closed by the user;
- graphic preference;
- “recently viewed” content;
- private notifications.
In other words: if the difference concerns a small, personal portion of the interface, it's best not to multiply the cache. Serve a public page and complete the details on the browser side. If, however, the difference concerns the entire HTML document, then a hash variant can be correct.
A complete example: device, country and language
In a more advanced case, you might want to distinguish the cache by three dimensions: device, country, and language. The VCL should first normalize all values and then use them in vcl_hash.
sub vcl_recv {
# Normalizzazione dispositivo
if (req.http.User-Agent ~ "(?i)mobile|android|iphone") {
set req.http.X-Cache-Device = "mobile";
} else {
set req.http.X-Cache-Device = "desktop";
}
```
# Normalizzazione paese
if (req.http.X-Country == "IT") {
set req.http.X-Cache-Country = "IT";
} elseif (req.http.X-Country == "DE") {
set req.http.X-Cache-Country = "DE";
} else {
set req.http.X-Cache-Country = "OTHER";
}
# Normalizzazione lingua
if (req.url ~ "^/it/") {
set req.http.X-Cache-Lang = "it";
} elseif (req.url ~ "^/en/") {
set req.http.X-Cache-Lang = "en";
} elseif (req.url ~ "^/de/") {
set req.http.X-Cache-Lang = "de";
} else {
set req.http.X-Cache-Lang = "default";
}
```
}
sub vcl_hash {
hash_data(req.url);
```
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
hash_data(req.http.X-Cache-Device);
hash_data(req.http.X-Cache-Country);
hash_data(req.http.X-Cache-Lang);
return (lookup);
```
}
This produces a richer, yet still controlled, cache key. The URL itself is no longer a single entity, but rather a family of cached objects. Each object represents a specific combination of variants.
However, it's important to consider combinatorial cost. If we add too many dimensions, the cache becomes fragmented. If we add too few, we risk serving the wrong content. The right design lies somewhere in the middle: few variations, very clear, all justified by a real difference in HTML.
Beware of purges: invalidate all variants
When using hash variants, purging must also be considered. If a product listing has multiple cached versions for each language, device, and country, it's not enough to mentally invalidate "the product page." You need to ensure that the invalidation removes all valid variants.
If you perform a point-in-time purge based only on a URL, you should ensure that your VCL logic removes all objects associated with that URL, not just a single variant. In some cases, it's preferable to use bans based on URLs, hosts, tags, or surrogate keys to invalidate groups of related objects.
This is especially important for WordPress and WooCommerce. When a product's price changes, all cached versions of that product page must be deleted: desktop, mobile, Italian, English, euro, dollar, and so on. If only one variant is purged, some users may continue to see outdated data.
Hashing Varnish and Developer Responsibility
Cache hashing isn't just a systems issue. It also directly affects application development. The developer must know which conditions actually modify the HTML output and communicate them predictably to the cache layer.
In the case of WordPress and WooCommerce, this means avoiding hidden and difficult-to-control logic. If a plugin changes its price based on a cookie, that cookie must be known. If a theme changes its layout based on wp_is_mobile()The cache must have a mobile/desktop variant. If a B2B system displays different price lists for each user role, a decision must be made whether to bypass, vary by group, or load prices dynamically.
A good architecture should clearly document:
- which pages are public and shareable;
- which pages are private and always in pass;
- which cookies can be ignored;
- which cookies indicate session or login;
- which headers enter the key cache;
- what variations exist for each URL;
- how all variants are purged.
This document should be shared between developers, systems engineers, and those managing the e-commerce site. Without this shared vision, you risk having Varnish configured correctly from a technical standpoint, but sabotaged by unpredictable application behavior.
The summary: different caches yes, but only when needed
Varnish hashing allows you to create multiple cached versions of the same URL, allowing you to handle complex cases like mobile/desktop, language, currency, country, or other shared application state. It's a very powerful tool because it avoids the false dilemma of "one cache fits all" versus "total bypass."
The correct choice is not to delete the cache as soon as a difference appears, but to determine whether that difference can be transformed into a controlled variant. When possible, Varnish can serve different objects as efficiently as a normal cache, provided the cache key is well designed.
The final rule is simple: You don't have to hash everything that comes from the client, but only what actually changes the content of the response.URLs, hostnames, normalized device names, normalized country names, language, or currency can all be good candidates. Entire cookies, full IP addresses, raw user agents, and tracking parameters are almost always too noisy.
For WordPress and WooCommerce, this approach allows for high performance even in complex scenarios: multilingual catalogs, multi-currency stores, localized content, differentiated layouts, and marketing campaigns. But it requires method. The cache must not randomly follow the application state: it must receive clear, standardized, and stable signals from development.
Logged-in users: when to bypass and when to separate
Logged-in users are one of the main reasons for cache bypassing. WordPress sets authentication cookies and a cookie. wordpress_logged_in_* which indicates the login status. If a request contains that cookie, the cache must be very careful, as the backend could generate personalized content.
For an editorial site, the simplest choice is often: anonymous users served by cache, logged-in users by password. This is fine when the number of logged-in users is low compared to anonymous traffic.
For membership, LMS, community, or B2B sites, however, bypassing all logged-in users can negate much of the benefit. In these cases, a more refined level of design is needed:
- public pages cached even for logged in users, if identical;
- personal snippets loaded via JavaScript;
- private, non-cached endpoints for notifications and account data;
- cache by role or group, if the content is the same for all users in the same group;
- header
Cache-Control: privateon the truly personal pages.
The point isn't "logged in user equals impossible cache." The point is to understand which parts of the page truly depend on the user's identity. If only the "Hi Marco" text changes, it makes no sense to make an entire 200 KB page uncacheable. You cache the page and update the user bar with JavaScript.
JavaScript as a cache ally
JavaScript is often seen as a performance issue, but when HTTP caching is present, it can become a formidable ally. The reason is simple: it allows you to serve a public, cacheable HTML page, leaving the browser to complete small dynamic parts.
Examples of elements that lend themselves well to being managed with JavaScript:
- login status in the menu;
- number of products in the cart;
- wishlist;
- cookie consent messages;
- banners already closed by the user;
- recently viewed products;
- light notifications;
- interface preferences;
- non-critical GeoIP content;
- tracking and campaigns.
Let's take the WooCommerce mini-cart. If the number of products in the cart is printed server-side in the header, the entire page might need to change per session. It's better to print a cacheable placeholder:
<a href="/cart/" class="cart-link"> Carrello <span id="cart-count" data-default="0">0</span> </a>
Then update the number via endpoint:
fetch('/wp-json/ms/v1/cart-count', {
credentials: 'same-origin'
})
.then(response => response.ok ? response.json() : { count: 0 })
.then(data => {
const counter = document.getElementById('cart-count');
if (counter) {
counter.textContent = data.count || 0;
}
});
This architecture has a significant advantage: the main page can remain identical for everyone, while private data travels in a separate request. That request can be excluded from the cache, protected, limited, optimized, and monitored without compromising the site's overall caching.
AJAX, REST API and admin-ajax.php
There are several ways to provide dynamic data in WordPress: admin-ajax.php, REST API, custom endpoints, dedicated templates or WooCommerce calls wc-ajaxFrom a caching perspective, it's important that these endpoints are clearly separated from public pages.
admin-ajax.php It's widely used, but it can become a bottleneck because it loads WordPress and is often called frequently. The REST API can be more streamlined, especially for well-designed custom endpoints. In both cases, you should avoid unnecessary calls, aggressive polling, and non-cacheable responses when they could have a short private or public cache.
Example REST endpoint for a user bar:
add_action('rest_api_init', function () {
register_rest_route('ms/v1', '/user-bar', [
'methods' => 'GET',
'callback' => function () {
if (!is_user_logged_in()) {
return [
'logged_in' => false,
];
}
```
$user = wp_get_current_user();
return [
'logged_in' => true,
'name' => $user->display_name,
'account' => wc_get_page_permalink('myaccount'),
];
},
'permission_callback' => '__return_true',
]);
```
});
For such an endpoint, you need to ensure that the cache doesn't store it as a public response that's the same for everyone. You can use specific headers:
add_filter('rest_post_dispatch', function ($result, $server, $request) {
if ($request->get_route() === '/ms/v1/user-bar') {
nocache_headers();
}
```
return $result;
```
}, 10, 3);
Separation is key: public HTML in the full page cache, personal microdata outside the full page cache.
Nonces, forms, and cached pages
WordPress often uses nonces to protect actions and forms. The problem is that a nonce printed within a cached page can expire or be shared improperly. This can cause seemingly random errors: forms that don't submit, AJAX actions that fail, checkout that doesn't behave properly, or "session expired" messages.
The solution depends on the type of form. A simple public form, such as a newsletter managed by an external provider, may not need a server-side WordPress nonce. A form that modifies data, submits orders, or performs sensitive actions must be on a non-cached page or must retrieve the token dynamically.
For WooCommerce, the checkout must remain uncached precisely because it contains status, nonce, session, shipping methods, and customer data. It's not a page that needs to be optimized with a full page cache. It needs to be optimized with good queries, object cache, high-performance PHP, a healthy database, and minimal redundant code.
Query strings, UTMs, and cache fragmentation
Another underestimated application problem concerns query strings. URLs like:
/prodotto/example/?utm_source=facebook /prodotto/example/?utm_source=google /prodotto/example/?fbclid=... /prodotto/example/?gclid=...
They can create many cached copies of the same page, even if the HTML content is identical. This phenomenon is called cache fragmentation: instead of a single reused object, tens or thousands of useless variations are produced.
The solution is to normalize or remove parameters from the cache key that don't affect rendering. In Varnish, you can clean up the URL or use specific VMODs; in NGINX, you can define a cache key that ignores certain parameters, or rewrite requests before lookups.
But be careful: not all parameters are useless. In WooCommerce, parameters like category filter, sorting, pagination, or search can actually change the content. These must remain in the cache key. Tracking parameters like utm_source, utm_medium, fbclid, gclid They normally shouldn't generate HTML variants.
Diagnostic Headers: Making Cache Behavior Visible
A developer can't work effectively with an invisible cache. It's important to be able to determine whether a response is a HIT, MISS, BYPASS, PASS, or EXPIRED. This is why it's useful to add diagnostic headers.
With Varnish you can add, for example:
sub vcl_deliver {
if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
```
set resp.http.X-Cache-Hits = obj.hits;
```
}
With NGINX:
add_header X-Cache-Status $upstream_cache_status always;
These headers allow the developer to immediately verify whether a change to the theme, plugin, or HTTP headers has broken the cache. During debugging, you should also check:
Cache-Control;Set-Cookie;Cookiein the request;Vary;- status code;
- HTTP method;
- query string;
- authentication header;
- any redirects.
A public page that always returns X-Cache: MISS o BYPASS It needs to be analyzed. The cause is often a cookie, a no-cache header, a plugin that opens a session, or a non-normalized variation.
Vary: Powerful but use with caution
The header Vary It tells the cache that the response varies based on one or more request headers. This is useful, but can be dangerous if used incorrectly.
A common and sensible example is:
Vary: Accept-Encoding
In this case, the cache distinguishes between compressed and uncompressed responses. The trickier one is:
Vary: User-Agent
This can generate a huge number of variations because there are so many user agents. It's best to first normalize the user agent into a few classes, such as desktop and mobile, and vary it using a controlled synthetic header.
Even more risky:
Vary: Cookie
Changing the Cookie header across the board often makes the cache nearly useless, as each user may have a different combination of cookies. The best strategy is to avoid Vary: Cookie generic and choose precisely which states should influence the cache.
Cache purge and content invalidation
A useful cache must also be purgeable. In WordPress, when a post is updated, a product price changes, or a category is modified, you can't always wait for the natural TTL to expire. A purge strategy is needed.
On a simple WordPress site, when you update a post, you should purge at least:
- the URL of the post;
- the homepage, if it shows the latest articles;
- the associated categories;
- the tag archives;
- feed or related pages, if cached.
In WooCommerce, when a product changes, the following may be affected:
- product page;
- product category;
- homepage if it shows featured products;
- “related products” blocks;
- internal research;
- product feed;
- landing page with WooCommerce shortcode.
Purge should be surgical but not naive. Purging too little leaves obsolete content. Purging everything after every change creates a storm of MISSES and backend load. A good WordPress/Varnish integration should understand the relationships between content and invalidate the right objects.
Object cache and full page cache are not the same thing
Many developers confuse object cache and full page cache. They are different and complementary tools.
La full page cache It preserves the final HTML response and can bypass PHP entirely. Varnish and NGINX reverse cache work at this level.
La object cache, such as with Redis or Memcached, stores intermediate results: queries, WordPress objects, transients, options, and application data. PHP still runs, but finds pre-prepared data faster.
In WooCommerce, a good object cache is important for the cart, checkout, account area, and logged-in users—that is, for all requests that can't benefit from the full page cache. The full page cache accelerates public traffic; the object cache helps with dynamic traffic.
A high-performance platform uses both levels, without confusing them.
WordPress Developer Checklist for Varnish
When developing or modifying a WordPress/WooCommerce site behind Varnish or another Web Cache, it is a good idea to follow a practical checklist.
-
- Does the page contain different data for each user?
- The theme uses
is_user_logged_in()to print custom HTML? - Does the plugin set cookies on all pages?
- Is a global PHP session started?
- Is the WooCommerce cart printed server-side in the header?
- Does the price change by country, currency, role, or group?
- Does the mobile version generate different HTML?
- Is the language in the URL or does it depend on cookies/headers?
- Do tracking query strings fragment the cache?
- Are the cart, checkout, and my-account pages excluded?
- Are my personal AJAX/REST endpoints excluded from the public cache?
- The headers
Cache-Controlare they consistent? - The answer contains
Set-Cookiefor no reason? - Is there a purge system after content updates?
- Do the diagnostic headers allow you to see HIT/MISS/BYPASS?
These questions should be part of the normal development process, just as they are with security, SEO, accessibility, and mobile compatibility.
Conclusion: Develop with cache in mind
A web cache isn't a hindrance to development. It's a performance booster, but it requires discipline. Application code must clearly distinguish between public and private content, between shareable HTML and personal data, between necessary and unnecessary cookies, between real variants and accidental fragmentation.
Varnish Cache, in particular, offers extremely powerful control, but it can't fix an application that generates private state everywhere on its own. If WordPress sets unnecessary cookies on every page, if WooCommerce is customized by printing server-side cart and user data in every template, if the GeoIP varies for non-normalized IPs, or if the mobile layout is generated without a consistent cache key, the cache loses effectiveness.
The right way is to design the application with a clear separation:
- Public and shareable HTML served by Varnish or Web Cache;
- personal fragments loaded via JavaScript, REST API, or dedicated endpoints;
- truly private pages excluded from the full page cache;
- controlled cookies and not used as a general storage;
- normalized variants for mobile, language, currency or country only when needed;
- consistent purge when the content changes.
For a WordPress and WooCommerce developer, working well with Varnish means stopping thinking of a page as something that's constantly regenerated, for everyone, on every request. Instead, it means designing cacheable, predictable, and secure HTTP responses. The result is a faster site, a lighter backend, greater resilience to traffic spikes, and a better end-user experience.
In a professionally managed infrastructure, caching isn't just a plugin to be activated. It's a central part of the architecture. And when application development, system configuration, and business logic all work together, Varnish can unleash its full potential: quickly serving public data, protecting private data, and keeping WordPress and WooCommerce performing even under real-world load.