This is the dev companion to Why your Magento 2 store feels slow even with Varnish and Redis. Hand that one to the merchant. This one's for you.
If you've audited more than a couple of Magento 2 stores, you've seen this: Varnish is on, Redis is on, hardware is fine, and the hit rate is well below where it should be. The box looks bored. PHP is doing all the work. On the stores where I've seen this pattern in the last year, it's the same short list of causes. Below is how I find them, in the order that's worth your time.
TL;DR
Hit rates below 30% on a Magento 2 store almost always come from one of these, usually stacked:
-
Set-Cookie: PHPSESSID=...on responses that should be guest-cacheable. -
PHP emitting
Cache-Control: no-storeorno-cachebecause an extension called$response->setNoCacheHeaders()or started a session. - The Redis database configured as the FPC tag store has 0 keys, which means tag-based purges fail silently and Varnish never stores tagged objects cleanly.
-
X-Magento-Varyfragmenting the cache per device, per currency, per store view, usually without anyone intending it. -
VCL that doesn't normalize layered-nav params or strip marketing params (
gclid,utm_*,fbclid,srsltid), so every ad click is a cold MISS. -
One or two Marketplace extensions calling
setPublicCookie()or starting a session on every request from a block, observer, or layout XML include.
Fix the cookie and the no-store header and the hit rate typically recovers substantially in an afternoon. The rest of the list is what gets you to 90%+.
Measuring your actual hit rate
Before you touch anything, get a baseline. If you don't have a number, you don't have a bug, you have a feeling.
The shortest path is varnishstat:
1varnishstat -1 | grep -E 'cache_hit|cache_miss'
Healthy output on a normal storefront: cache_hit an order of magnitude higher than cache_miss. If cache_miss is in the same ballpark as cache_hit, or worse, is higher, you're in the territory this article is about. Store the exact numbers. You'll want a before/after.
For a live read, varnishstat -1 gives you a snapshot; varnishstat (no flags) is the interactive TUI. The MAIN section has cache_hit, cache_miss, cache_hitpass, and cache_hitmiss. A high cache_hitpass means requests are matching a hit-for-pass object, which is usually a symptom of the cookie/no-store problem below, not a separate bug.
If you want per-URL granularity, sample varnishlog for a minute:
1varnishlog -g request -q 'ReqMethod eq "GET"' -i ReqURL,RespHeader:X-Magento-Cache-Debug,VCL_call
Healthy: most GETs for category and product URLs log VCL_call HIT. If you see MISS and X-Magento-Cache-Debug: MISS on a plain /mens-shoes with no cookies, you have a cacheability problem, not a traffic problem.
The usual suspect: Set-Cookie on every response
This is the killer. Varnish ships with VCL that treats any response carrying Set-Cookie as uncacheable, and Magento's default VCL keeps that behavior for anything outside its allow-list. One rogue setPublicCookie() call in a block that renders on every page, and you've turned the entire storefront into pass-through traffic.
Inspect the response. This is the single most useful command in the article:
1curl -sI https://storefront.example.com/ | grep -iE 'cache-control|set-cookie|x-magento-vary|x-cache|age'
Healthy output on a guest homepage:
1Cache-Control: max-age=86400, public, s-maxage=864002X-Magento-Vary: ... # hash present, that's fine3Age: 43 # Varnish is serving a cached object
Broken output, what you're probably about to see:
1Cache-Control: no-store, no-cache, must-revalidate, max-age=02Set-Cookie: PHPSESSID=abc123...; path=/
If Set-Cookie: PHPSESSID shows up on a request where no session should have started, something in the request chain started one. PHP's session_start() is eager: anything that reads $_SESSION, calls Magento\Framework\Session\SessionManager::start(), or uses a class that transitively does, will emit the cookie. Varnish then refuses to cache the response.
To find the source fast, grep app/code for the usual offenders:
1grep -rn --include='*.php' -E 'setPublicCookie|->start\(\)|SessionManager|CustomerSession|CheckoutSession' app/code/ | grep -v Test
Healthy: the only hits are in checkout, customer, and wishlist modules. Anything in a block, observer, layout processor, or generic helper rendered on catalog pages is a suspect. I've found session starts inside header blocks, inside promotional banners, inside GeoIP helpers, and once inside a "currency detector" that had no business touching the session.
session_start. Magento's session manager wraps it. Grep for SessionManager, CustomerSession, CheckoutSession, ->start(, and setPublicCookie. The last one is sneaky: setPublicCookie() doesn't start a session itself, but common Marketplace cookie-consent extensions run a DB query on every call, which we'll cover below.
The second suspect: Cache-Control: no-store from PHP
Sometimes the cookie isn't the issue. The response itself is telling Varnish to not cache it, via PHP. This happens when an extension writes Cache-Control: no-store or no-cache directly into the response headers, usually in a beforeDispatch plugin or a dispatch observer.
Magento's \Magento\PageCache\Model\Config::isEnabled() and the PageCacheHeader plugin are supposed to set max-age and public. If something runs after them and overrides, you lose. $response->setNoCacheHeaders() is the usual culprit.
Check your merged plugin/observer configuration:
1bin/magento dev:di:info 'Magento\Framework\App\Response\Http' 2>&1 | head -50
You're looking for plugins in the Magento\Framework\App\Response\Http pipeline that shouldn't be there. A plugin from a non-core module with Around on sendHeaders or sendResponse is a red flag. Read it. If it conditionally sets Cache-Control: no-store based on a cookie that exists on every request, you've found your regression.
A minimal correct plugin looks like:
1final class PreserveCacheHeaders2{3 public function beforeSendResponse(\Magento\Framework\App\Response\Http $subject): void4 {5 // do nothing with cache headers on cacheable routes6 }7}
A broken one looks like $subject->setHeader('Cache-Control', 'no-store', true) called unconditionally. That one line drops your hit rate to zero on every route it touches.
FPC Redis store is empty? Your tag-based purging is broken
Magento stores its full-page cache tags in a dedicated Redis database. Which database number that is depends entirely on how the store is configured in app/etc/env.php — there is no requirement that it be any specific index. Find out what's actually configured before you query Redis:
1grep -A 10 'page_cache' app/etc/env.php
Look for the backend_options -> database value under the page_cache frontend entry. That's the DB index you want to query.
1redis-cli -n <your_page_cache_db> dbsize
Healthy: anywhere from a few thousand to hundreds of thousands of keys on an active store. Zero is not.
If the FPC database returns (integer) 0, walk the usual checklist:
-
app/etc/env.phphascache->frontend->page_cachepointing at a Redis instance (notdefault, not missing), with adatabasekey explicitly set. -
CM_Cache_Backend_Redisis the configured backend class, and the database index inenv.phpmatches what you're querying. -
bin/magento cache:enable full_pagereports enabled. -
Varnish's
vcl_backend_responsein your VCL isn't stripping theX-Magento-Tagsheader on backend response (old custom VCLs sometimes do this for "cleanliness" and break tag purges).
Why it matters: without a populated FPC tag store, tag-based invalidation doesn't work. bin/magento cache:flush hits the default cache, Varnish's PURGE request goes out, but the tag store it's meant to coordinate with has nothing in it. Varnish appears to cache. But objects age out on TTL instead of on content change. Hit rate looks OK on static catalogs and tanks the day after a bulk product import, because every product page misses and Varnish refills the whole cache cold.
X-Magento-Vary: when desktop and mobile fragment your cache
X-Magento-Vary is how Magento tells Varnish "this response is specific to this customer group / currency / store view". The hash covers the HTTP context variables registered in the DI container — by default: logged-in status, customer group, currency, and store. Extensions can add more. Varnish stores one cached object per unique vary hash. Too many distinct hashes and you're caching per-visitor, which is the same as not caching at all.
Check what you're actually fragmenting on:
1for i in 1 2 3 4; do curl -sI -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" \2 https://storefront.example.com/ | grep -i x-magento-vary; done
Healthy: the same hash, four times. Meaning the vary hash is stable across a device class and doesn't flip on every request.
The common pathology is desktop and mobile generating different vary hashes for the same logical page, doubling your working set. If you're doing separate desktop/mobile themes via Magento's design config, that's intentional and the hit rate ceiling is effectively halved. If you're not, and you're still seeing two hashes per URL, something in design/*/view.xml or in a theme plugin is flipping the hash based on user-agent unnecessarily.
Sampling desktop TTFB against mobile TTFB on the same URL, from the same location, is a cheap sanity check. If desktop is 2x+ slower than mobile on cacheable category pages, you're probably MISS-ing on desktop and HIT-ing on mobile (or vice versa) because you've fragmented the cache along a device axis you didn't plan for.
VCL hygiene: layered-nav params and marketing params
Your VCL's vcl_recv decides what ends up as the cache key. Two knobs matter.
Layered navigation params. A URL like /mens-shoes?brand=nike&price=50-100&p=2 should be cacheable. Every Magento site hits this. If your VCL doesn't normalize the query string (sort params alphabetically, lowercase, drop nulls), ?brand=nike&price=50-100 and ?price=50-100&brand=nike are two different cache entries. Multiply across filter combinations and you're back to cold cache.
The standard fix is a std.querysort() call and a conservative whitelist of filter params in vcl_recv. The Magento reference VCL in vendor/magento/module-page-cache/etc/varnish6.vcl already includes std.querysort(req.url). If your VCL doesn't, add it.
Marketing params. gclid, fbclid, utm_source, utm_medium, utm_campaign, srsltid, _gl, epik, mc_cid, mc_eid are not semantically relevant to the page. They're attribution bolted on by ad platforms. If you include them in the cache key, every single ad click is a cold MISS. Strip them before the hash:
1sub vcl_recv {2 # strip marketing params before querysort3 set req.url = regsuball(req.url, "(\?|&)(gclid|fbclid|utm_[^=]+|srsltid|_gl|epik|mc_cid|mc_eid)=[^&]*", "");4 set req.url = regsub(req.url, "\?&", "?");5 set req.url = regsub(req.url, "\?$", "");6 set req.url = std.querysort(req.url);7}
Healthy after this: Google Ads traffic TTFB is comparable to organic traffic TTFB. Broken before this: Google Ads traffic can be 2 to 3x slower than organic on the exact same URL, because every gclid value creates a new cache entry.
X-Magento-Vary, private_content_version, form_key, mage-cache-sessid, section_data_ids) and occasionally serve cached pages to logged-in customers. If your CDN has a cache_everything rule on HTML paths, disable it.
Module patterns we see over and over
The same handful of Marketplace modules show up in these audits. Three patterns worth calling out.
Plumrocket CookieConsent: a DB query per setPublicCookie(). The module runs a database query for every public cookie set, and it sets several per page. On a cold page load you can see four to ten extra queries just from this one extension. Beyond the query cost, each setPublicCookie() lands on response headers Varnish then has to decide about. The fix is either removing the module or caching the cookie-config lookup statically in memory for the request lifetime.
Bss_PromotionBar: session-start on every request. The observer that fetches the customer group ID for the promo bar calls into the customer session, which triggers session_start(), which emits Set-Cookie: PHPSESSID on responses that should be guest-cacheable. Adding a cache layer around the group-ID lookup helps with DB load, but as long as the observer still touches the session, Varnish still won't cache the response. The only real fix is rewriting the lookup to use a stateless source, like a cookie set by a small edge-side logic block, or a client-side AJAX personalization call after the cacheable HTML lands.
Bss_FacebookPixel: the correct pattern. Worth naming because it's the positive case. The module's session storage class can be rewritten as an in-memory DTO scoped to the request, with no calls into the Magento session manager. No session contention, no Set-Cookie, and the pixel still fires. If you're writing a Magento module that wants "session-scoped" data for a guest, this is the pattern: request-scoped DTO, not session.
Amasty_Geoip and Plumrocket_GeoIPLookup. GeoIP modules have a strong tendency to load heavy databases or start sessions on every request to determine country. On a Varnish-fronted store, any per-request GeoIP logic is incompatible with caching. Either move the lookup to an edge worker and pass the result as a header Varnish can vary on, or do it client-side after the cacheable shell lands.
MSP_NoSpam and older Mageworx modules. Historical patterns here involved over-broad session starts on every controller dispatch. Most are deprecated in favor of stateless approaches. If you're on an old build still carrying these, removal is usually the right call.
General rule: any module that wants to personalize cacheable HTML is doing something wrong. Personalization belongs in the private_content_version / section_data_ids AJAX layer, not in the initial HTML response.
Related reading
-
Redis Session Locking in Magento 2: When
disable_locking=1Saves Your Checkout. Varnish and Redis session locking compound on the same slow-page path; if your hit rate is right and the store still feels slow on logged-in interactions, the lock is the next thing to look at. - Observer Recursion in Magento 2: The Hidden Cost of sales_order_save_after. When the slow page is the thank-you page after checkout, the cache side is innocent and an observer cascade is the usual cause.
- The N+1 Trap on Magento 2 Product Pages: getUsedProducts and Friends. The other half of "PDP feels slow". Cache misses explain one slow request; N+1 explains why every request is slow once it reaches PHP.
The 8-point Magento Varnish audit checklist
-
Baseline the hit rate
Run varnishstat -1 | grep -E 'cache_hit|cache_miss' and record the ratio. Under 30% is broken. Under 80% is worth investigating. Re-run after every fix to confirm movement.
-
Inspect response headers on a guest page
curl -sI the homepage and a category. Cache-Control should be max-age=N, public. No Set-Cookie on guest responses. X-Magento-Vary present, Age incrementing on repeat requests.
-
Grep for rogue session starts
grep -rn --include='*.php' -E 'setPublicCookie|->start\(\)|SessionManager|CustomerSession|CheckoutSession' app/code/. Any hit outside checkout, customer, or wishlist code is a suspect. Read it, verify it's session-necessary.
-
Verify the FPC Redis database is populated
grep -A10 'page_cache' app/etc/env.php to find the configured database index, then redis-cli -n <that_db> dbsize. Zero means FPC tag storage is broken, tag-based purges fail silently, and the cache only ages on TTL. Fix env.php first, then bin/magento cache:flush and recheck.
-
Audit the response-header pipeline
bin/magento dev:di:info 'Magento\Framework\App\Response\Http'. Any non-core plugin on sendHeaders or sendResponse is a candidate for overwriting Cache-Control. Read each and confirm it doesn't force no-store.
-
Confirm X-Magento-Vary stability
Hit the same URL four times, same UA, same cookies. Vary hash must match. If it drifts, something is varying the hash on request-scoped state it shouldn't be.
-
Normalize query strings in VCL
std.querysort() must be in vcl_recv. Strip gclid, fbclid, utm_*, srsltid, _gl, epik, mc_cid, mc_eid before querysort. Whitelist the layered-nav params you actually want cached separately.
-
Identify known-bad extensions
Plumrocket CookieConsent (DB per cookie), Bss_PromotionBar (session-start in observer), GeoIP modules (per-request DB lookups). Confirm whether they're on. If yes, either remove, rewrite stateless, or budget for the hit rate they cost you.
Stuck at 25% and running out of ideas?
We run focused Magento 2 performance audits. You get the varnishstat numbers, the exact extension(s) killing your hit rate, a VCL diff, and a prioritized fix list. No 80-page PDF. Two to three days, straight to code.