WordCamp Europe Kraków 2026 — WooCommerce Deep Dive: 50 Shades of Cache — Mateusz Zadorożny
Who's talking SHIFT64
Mateusz Zadorożny
Mateusz Z.
Mateusz Zadorożny Founder, SHIFT64 — WooCommerce Performance
2012 →

Started building with WordPress

2017 →

Focused on WooCommerce performance

By day

Lead e-commerce engineering at Merida.

SHIFT64 runs a BYOS - Bring Your Own Server model: clients own their dedicated infrastructure, I design and tune it. WooCommerce only, by preference.
The agenda & the promise
Caching isn't one thing.
It's fifty.

By the end you'll know how to design cache layer by layer, how to measure it, and how to not blow up your store. No theory for theory's sake, but real examples, live demos, and debugging real cache misses.

01 / DESIGN IT

Layer by layer

opcacheobjectpageedge

Different layers jobs. Where each one lives and what it's allowed to hold.

02 / MEASURE IT

Prove it's working

k6response headers

Load tests and the headers that tell you HIT or MISS. No guessing, no vibes.

03 / DON'T BREAK IT

Stay correct

nonce·prices·categories

The things you must never serve stale and how cache quietly corrupts them.

What's not here: selling you a one-click plugin. There is no magic checkbox.

The hook
Perfect score.
Trashed store.
Lab / PSI 98/100
Field / CrUX FAILED
Live Woo store PageSpeed Insights: Performance 98, Core Web Vitals Assessment Failed
How we'll work
How we'll work
Ask as we go →
Repeat per layer
Theory Short demo Next layer
At the end Real-world disasters

Every layer gets weighed on the same three lenses:

Performance How much faster the page actually gets.
Stability How it holds up when traffic spikes.
TTFB Time to first byte - the number that doesn't lie.
What cache actually is
The formal definition
Freshness Time We trade freshness for time. That trade is the whole game.
The practical definition: our way
"Cache what's cheaper to serve than to compute."
Less server work per request
=
More requests served on the same iron
Glossary
Five words,
used all day

Lock these in now - every layer that follows speaks this language.

HIT
Served straight from cache. Zero backend work.
MISS
Not in cache. Build it, then store it for next time.
STALE
Past its freshness window - may still be served while it refreshes.
BYPASS
A rule says never cache this. Always goes to origin.
EXPIRED
TTL elapsed. The next request rebuilds and re-stores it.
Invalidation
TTL vs. event-based invalidation
By the clock TTL Set a lifespan. The copy expires after N seconds - whether or not anything actually changed. expires_in = 3600s
vs
By the event Event-based Purge the moment something changes - a price, a stock level, a published order. product.updated → purge
E-commerce lives on events, not on the clock.
The metric we want to maximize
Cache
hit ratio
HITS HITS + MISSES
push it up
BUT 100% IS NEVER POSSIBLE

Some routes must always be a MISS, as they're personal, live, and can never be shared:

checkout cart my-account
Measuring hit ratio
Where to read it

Three places tell you HIT or MISS.

01 Response
headers
x-cache: HIT
02 Server
panel
Redis · 12.4k hits Object cache and OPcache dashboards show live hit rates.
03 Logs* grep HIT access.log Count HIT vs MISS across real traffic over a real window.

Measure the baseline first. You can't improve a number you've never looked at.

Architecture
Cache should be designed,
not bolted on.
01Server
02OPcache
03Object
04Page
05Edge
A shortcut, not optimization A plugin "at the end" MIGHT hide the problem. It doesn't solve it.
Example A
Example A
Landing
+ checkout
A small set of URLs — a handful of pages.
High repeat traffic — the same pages, over and over.
Hit once, served from cache for everyone after.
The verdict Page cache earns its keep from day one. Easy mode.
Example B
Example B
5,000 products,
50 visits a day
Most URLs never get a 2nd HIT within their TTL.
Preloading or warming 5k pages might be a waste.
You'd rebuild cache faster than anyone reads them.
The verdict Strategy should follows the shape of your traffic not the product count.
Example B — by the numbers
Warm the catalog? Do the math.
5,000 products + 100 categories + 100 tags = 5,200 URLs

A 4-hour TTL means every warmed page expires 6× a day — so keeping them all warm costs 5,200 × 6 rebuilds.

31,200 forced rebuilds / day
vs
~50 real visits / day
≈ 624 WASTED REBUILDS FOR EVERY REAL VISITOR.
Takeaway — A vs B
"Which plugin?"
"What does my traffic look like?"
Different store = different cache design.
The layer map
The request path, layer by layer
Userthe visitor
EdgeCDN / POP
Pagefull HTML
Objectquery results
OPcachebytecode
PHPexecutes
DBMySQL
← closer to the user · cheaper & faster origin · slower & more expensive →
OPcache — what it is
Layer 02 · OPcache
A cache of compiled PHP bytecode. The result of parsing your code, kept in memory.
✓ It is Your compiled code, reused on every request instead of being re-parsed.
✕ It is not Your data, your HTML, or your query results. Those need other layers.
Goodies Goodies QR Scan for the goodies
OPcache — in the request cycle
What we save vs. what stays
Parse + compile ✓ cached — skipped on repeat
Execute Running the bytecode to build the actual response for this request. always runs
!

Without OPcache, every request recompiles all of WordPress + WooCommerce from scratch.

OPcache — pitfalls
Where OPcache bites back
revalidate_freq Set too high and you keep serving stale bytecode long after the file changed.
deploy without reset New code lands, but the old bytecode stays cached. Always flush on deploy.
a broken file cached A fatal slips in mid-deploy and gets cached - every request errors until you reset OPcache.
memory_consumption Too small for a large Woo install: OPcache thrashes and evicts, killing the win.
Live demo
Live demo
Query
Monitor
OPcache status in the Environment panel - plus PHP execution time and query count. Our reference point.
Watch forNO DRAMA YET - THESE ARE THE NUMBERS K6 WILL MOVE.
Query Monitor · Environment
Live screen-share
OPcache · PHP time · query count
Live demo
Live demo
RPS and p95 side by side - OPcache enabled, then disabled, on the exact same hardware.
The ahap95 collapses, RPS jumps. Same box. Just compiled code.
k6 · run summary
Live load test
RPS · p95 · OPcache on vs. off
Object cache — what it is
Layer 03 · Object cache
A shared store - Redis or Memcached — that persists results across requests.
✓ With a backend WP_Object_Cache backed by Redis survives between requests - queries, options, computed values.
✕ Without one Default WordPress keeps the object cache per-request only. It forgets everything at the end.
Object cache — what Woo uses it for
What Woo leans on it for
Query results - expensive product and term lookups, served from memory.
Options & transients - the autoloaded options and the transients backend.
Fewer round-trips to SQL - the database stops being the bottleneck under load.
Pitfall — autoload bloat
Object cache won't fix
a bad database
A bloated alloptions becomes one giant entry - loaded on every single request.
Object cache faithfully caches the bloat. Now it's fast bloat.
!

Clean up autoload first. Then cache what's actually left.

Live demo
Live demo
Query Monitor
Redis on
The same Environment view as the baseline - now with the object cache enabled.
Watch forQuery count and total time drop. Same page, fewer round-trips.
Query Monitor · Redis ON
Same view as slide 20
query count ↓ · time ↓
Transients — what they are
Transients API
WordPress's built-in temporary cache: a value, a key, and a TTL.
✓ With object cache The transient lands in Redis. Fast, and off the database entirely.
✕ Without it It lands in the options table - and an autoloaded one weighs on every request.
TRANSIENTS: WHEN TO USE THEM
Right tool, wrong tool
Reach for them
Expensive, rarely-changing computations - a homepage feed, a report, a slow API call.

NOT A DATABASE

If you’re querying it like a table, it probably belongs in a custom table.

Transient vs. object cache vs. custom table
When to reach for which
TransientObject cacheCustom table
ScopeOne value, with a TTLAny read, app-wideStructured rows
PersistenceUntil TTL or flushUntil evictedPermanent
InvalidationTTL or deleteKey delete / flushYou own it (SQL)
Reach for itCheap to recompute, rarely changesHot reads, shared across requestsYou query it like data
PAGE CACHE - THE FULL HTML
Layer 04 · Page cache
The request finishes before PHP even boots.
nginx FastCGI Varnish page-cache plugin

Full HTML, served straight off disk or RAM. The single biggest jump on the whole list.

Page cache — how the plugin works
How a disk-cache plugin serves a page
01 Request A visitor hits the URL.
02 WP boots PHP starts and sees WP_CACHE = true. uses PHP
03 The drop-in advanced-cache.php runs early and checks disk for a cached file. uses PHP
04 Serve HIT → echo the file and exit. MISS → full WP builds it, writes it to disk.

It still runs PHP and loads WordPress just a little — enough to find the file. Server-level cache, next, runs none at all.

What you must never cache
cart checkout my-account header cart count anything personalized

These are deliberate MISSes — by design, not by accident.

WooCommerce cookies & bypass rules
See these cookies? Bypass.
woocommerce_items_in_cart woocommerce_cart_hash wp_woocommerce_session_* no_cache

When any of these are present, the visitor has state. Skip the page cache and serve fresh.

Preload & cache warming
Back to Example B
Worth warming
Bestsellers and key landing pages - the handful of URLs real traffic actually hits.
A waste
Warming 5,000 dead SKUs nobody visits. You rebuild caches faster than anyone reads them.
Page cache — where it lives
Server-level vs. plugin-level
More performant nginx / Varnish Caches before PHP boots — the fastest path. Trade-off: needs server-level access to set up. x-fastcgi-cache: HIT
vs
Easier to use WP plugin Installs from the dashboard — no server access needed. Trade-off: still boots PHP and WordPress core. wp boots → maybe cache
RAW PERFORMANCE VS. EASE OF USE
Live demo
Live demo
curl -I
x-fastcgi-cache
HIT / MISS on a product page versus the cart - the cacheability boundary, live.
Watch forProduct caches. Cart never does. The line is right there in the headers.
terminal · curl -I
product → HIT
cart → MISS
EDGE / CDN - CACHE CLOSEST TO THE USER
Layer 05 · Edge
Cloudflare and friends - HTML and assets served from a POP near the customer.
Static assets, and - carefully - HTML, cached at the edge.
Served from the city nearest the visitor, not your origin.
TTFB drops globally - not just for users next to your server.

On an edge HIT, the request NEVER REACHES OUR SERVER AT ALL - the edge answers, the origin stays idle.

CACHE EVERYTHING - EXCEPT STATE
Rule one: cache based on server headers - HTML included.
Rule two: bypass by cookie or path - cart, checkout, account, logged-in users.

One missed bypass = a cart leaking between users. Get this rule right.

Edge vs. cookies & query strings
What kills edge hit ratio
Cookies: one unique cookie can fragment the cache per visitor.
Query parameter: every ?utm spins up a separate entry.
No URL normalizatio: /shop and /shop/ cached twice.

Which all leads straight to the parameters problem.

Free tool Edge Inspector QR Edge Inspector edge-inspector.zadorozny.rocks
Why the field CWV fails
PSI 98 · CWV failed PageSpeed Insights: 98 lab score, Core Web Vitals failed in the field
Back to the hook
Often it's the params, not the page
?utm · ?fbclid cache MISS slow backend CWV failed

Lab PSI tests the clean URL - 98. The field sees the param-MISS reality. The backend was never fast; the cache was just hiding it.

Monitor your URL params. A high cache hit ratio is what protects your field CWV.

Live demo
Live demo
cf-cache
-status
HIT / MISS / DYNAMIC / BYPASS across different paths on the live site.
Watch for
curl -I · Cloudflare
HIT · MISS · DYNAMIC · BYPASS
across paths
Edge purge after a product change
Consistency vs. speed
Change a product → the edge still holds the old HTML.
Cache-tag or purge-by-URL the moment it changes.

Which is the whole invalidation problem - and we'll come back to it.

Parameters / query strings
Every parameter is a new entry
?utm_source ?fbclid ?orderby ?add-to-cart
Each combination is potentially its own cache entry.
Hit ratio quietly collapses as the URL space explodes.
Ignore vs. cache the variants
The trade-off
Ignore the param
Higher hit ratio - one entry serves all. Risk: you serve the wrong variant.
Cache per param
Always correct - each variant is its own entry. Cost: hit ratio drops.
Allow / deny lists + normalization
Strip first, then cache
strip · utm_* strip · fbclid strip · gclid
Strip marketing params before they reach the cache key.
Whitelist only params that change content - orderby, paged.
Normalize trailing slashes and casing so one page is one entry.
Goodies Goodies QR Scan for the goodies
Faceted nav & filters
FILTERS MULTIPLY URLS - FAST
color× size× brand× price= thousands of URLs
noindex filter combinations so crawlers don't generate them.
Limit selectable facets - cap the combinations.
Cache only the popular combinations; let the long tail miss.
Disaster 01
Disaster 01
Caching the nonce
A cached nonce breaks AJAX, add-to-cart, and every form for logged-in users. A classic.
Pro tip: cache TTL should never outlive nonce validity (~24h by default).
Why it happens Nonces change by user and time. Cached HTML can easily serve an invalid nonce.
The fix Keep nonces out of shared page cache. Generate them dynamically, or bypass cache where validation is required.
Disaster 02
Disaster 02
No category purge
You change a price or stock - but the category listing still shows the old one.
Why it happens Invalidation only hit the product page - not the listings and widgets the product appears in.
The fix Purge every page the product appears on. Tag by product ID and purge the whole tag at once.
Disaster 03
Disaster 03
Caching personalized content
"Hi, Mateusz" - or a cart - cached and served to a completely different user.
Why it happens A personalized response got a cacheable status, and the edge or page cache happily shared it around.
The fix Bypass on session cookies. Personalized = never shared. This is a data leak, not a perf bug.
Disaster 04
Disaster 04
Stale commerce data
Showing outdated prices, availability, or promotions. The user sees one thing; the backend knows another.
Why it happens The cache was still valid. The product data was not.
The fix Every product change should purge affected pages immediately. Freshness must be event-driven, not time-driven.
Disaster 05
Disaster 05
Double caching
Edge caches, page caches, a plugin caches - and nobody knows who invalidates whom.
Why it happens Layers were added independently, with no agreed order and no owner of invalidation.
The fix Make layer order and ownership explicit. One owner per layer, one invalidation path.
Disaster 06
Disaster 06
Purging the entire site
One product update invalidates 50,000 cached pages. The cache stampede begins.
Why it happens A blunt "purge all" on every save. Now every page is a MISS at once, and the origin gets hammered.
The fix Purge only the affected URLs. Tag by product and invalidate just the pages it touches.
THE ESCAPE HATCH - ESI / FRAGMENT CACHING
The escape hatch
Cache the whole page - but punch a hole for one live fragment (the header cart).
✓ When it makes sense One small dynamic island on an otherwise static page - the cart count in the header.
✕ When it doesn't If most of the page is dynamic, ESI is just needless complexity. Don't reach for it by default.
Honest measurement
A single curl lies
RPS p95 p99 hit ratio @ load
Measure RPS, p95 and p99 - never just an average.
Read hit ratio under real concurrency, not at rest.
A single curl shows one lucky request. k6 shows the truth.
Header debugging - the cheat sheet
Read the layer that's missing
HeaderWhat it tells youLayer
x-cacheHIT or MISS at the page cachePage
ageSeconds since this copy was storedAny
cf-cache-statusHIT / MISS / DYNAMIC / BYPASSEdge
x-fastcgi-cachenginx FastCGI HIT or MISSServer
Invalidation strategy - the two hard things
When to purge vs. when to TTL
Woo eventStrategyAction
Price / stock changeEventPurge the product + its listings now
New orderEventPurge stock-sensitive pages
Theme / plugin updateEventPurge everything - output can change site-wide
New blog postTTLLet it expire - minutes are fine
Nothing changedTTLRide the TTL, no purge
Monitoring hit ratio over time
A trend, not a reading
Track hit ratio over time - one reading tells you almost nothing.
Alert when it drops - a deploy, a new param, a broken rule.
A falling ratio is an early warning, before users ever feel it.
Cache design checklist: the SHIFT64 way
Foundation first, edge last
01ServerFast single-tenant iron, tuned
02OPcacheCompiled bytecode, reset on deploy
03ObjectRedis backend, clean autoload
04PageServer-level, correct bypass
05EdgeStrip parameters, bypass state
Goodies Goodies QR Scan for the goodies
Thank you
Stop fixing.
Start scaling.
Q & A Ask me
anything
[email protected]
Connect LinkedIn /in/mateusz-zadorozny
Work together Audit my store — BYOS Bring your own server. WooCommerce performance, tuned end to end.
Goodies Goodies QR Scan for the goodies
Thank you — WordCamp Europe Kraków 2026