What is Cache Fragmentation?
Cache fragmentation occurs when your page cache generates too many unique cache keys — each representing a slightly different version of the same page. The result: a cache filled with entries that are almost never reused, a hit rate that crashes toward zero, and PHP/MySQL handling nearly every request as if caching did not exist at all.
We have seen sites with over 2 million cached pages where 98% of entries had a lifetime hit count of exactly one — meaning the cache was being bypassed in practice, while still consuming all available disk space and Redis memory.
How to Measure Your Hit Rate
Before optimizing, measure. For Redis object cache:
# Redis hit/miss stats
redis-cli info stats | grep -E "keyspace_(hits|misses)"
# Calculate hit rate:
# hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses) * 100
# Watch live cache operations
redis-cli monitor | grep -v "PING"
For full-page cache (NGINX FastCGI), check the cache status header:
curl -sI https://yoursite.com | grep -i x-cache
# X-Cache: HIT → served from cache
# X-Cache: MISS → PHP generated this response
# X-Cache: BYPASS → cache deliberately skipped
Target hit rates: Content sites: >85% | WooCommerce stores: >70% | Below 50%: you have a fragmentation problem that is actively hurting performance.
The 5 Main Causes of Cache Fragmentation in WordPress
1. Query Strings
This is the number one cause on most WordPress sites. Each URL with a unique query string gets its own cache entry. A single article can have hundreds of cached variants:
/article/ → cache key A
/article/?ref=email → cache key B
/article/?utm_source=google&utm_medium=cpc → cache key C
/article/?fbclid=Ab3Xk9_2mFpQ... → cache key D (unique per user, per click)
During a marketing campaign, one article URL can generate 50,000+ unique cache keys — all for essentially the same page.
2. Cookie Variations
Some cache configurations vary the cache key by cookie value. This is necessary for logged-in users, but plugins often set cookies for anonymous visitors (A/B testing tools, analytics, chat widgets, affiliate trackers). Each unique cookie value = unique cache key = near-zero reuse rate for anonymous traffic.
3. WooCommerce Dynamic Pages in Cache
Cart, checkout, and My Account pages must never be cached. If a caching misconfiguration includes them, every user session generates unique cache entries requested exactly once and never purged. Combined with high traffic, this fills Redis or disk cache rapidly.
4. Search Results Pages
URLs like /?s=wordpress+cache+problem are unique per query. With a site search box, bots and users generate thousands of unique search result URLs that are cached once and never revisited. Always exclude /?s=* from page cache.
5. Pagination With Dynamic Sorting
WooCommerce shop pages with user-selectable sorting (price asc, price desc, popularity, newest) multiply cache entries. With 500 products at 24 per page: 21 pages × 6 sort options = 126 unique cache entries per category. With 50 categories: 6,300 entries that each need individual warming and have low reuse rates.
Fix 1: Strip Tracking Parameters at the Edge
This is the highest-leverage fix. Strip all tracking and irrelevant parameters before they reach the cache key. Implement at the CDN layer so it applies to all requests before touching your origin.
Cloudflare Worker
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Strip params that never affect page content
const trackingParams = [
"utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
"fbclid", "gclid", "msclkid", "ttclid", "ref", "source",
"mc_cid", "mc_eid",
"_ga", "_gl",
];
trackingParams.forEach(p => url.searchParams.delete(p));
// Bypass cache for authenticated/cart users
const cookie = request.headers.get("Cookie") || "";
if (
cookie.includes("wordpress_logged_in") ||
cookie.includes("woocommerce_cart") ||
cookie.includes("wp_woocommerce_session")
) {
return fetch(request);
}
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (response) return response;
response = await fetch(request);
if (response.headers.get("content-type")?.includes("text/html") && response.status === 200) {
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
},
};
NGINX: Strip Params from FastCGI Cache Key
set $skip_cache 0;
if ($http_cookie ~* "wordpress_logged_in|woocommerce_cart_hash|wp_woocommerce_session") {
set $skip_cache 1;
}
if ($request_uri ~* "(/cart|/checkout|/my-account|/wp-admin|/wp-login|/feed|/\?s=|/\?wc-ajax=)") {
set $skip_cache 1;
}
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
# Build cache key without tracking params
map $request_uri $cache_uri {
~^(.*?)\?(.*&)?(utm_[^&]*|fbclid[^&]*|gclid[^&]*)(&.*)?$ $1;
default $request_uri;
}
fastcgi_cache_key "$scheme$request_method$host$cache_uri";
Fix 2: Comprehensive Cache Exclusion Rules
Paths to Always Exclude
# WordPress admin
/wp-admin/
/wp-login.php
/xmlrpc.php
# WooCommerce dynamic pages
/cart/
/checkout/
/order-received/
/my-account/**
/?add-to-cart=*
/?wc-ajax=*
/?removed_item=*
# Search (prevents search query fragmentation)
/?s=*
# Feeds (low value, high fragmentation risk)
/feed/
/feed/**
# REST API (should not be page-cached)
/wp-json/**
Cookies That Should Bypass Cache
wordpress_logged_in_.*
woocommerce_cart_hash
woocommerce_items_in_cart
wp_woocommerce_session_.*
comment_author_.*
PHPSESSID
Fix 3: Redis Configuration for Fragmentation Control
If Redis runs without a memory limit, it grows until the server OOM-kills it — wiping the entire cache at once. With a memory limit and the right eviction policy, Redis gracefully removes the least-recently-used entries automatically.
# redis.conf
maxmemory 512mb # 25–30% of total server RAM
maxmemory-policy allkeys-lru # Evict least recently used when full
lazyfree-lazy-eviction yes # Evict asynchronously (never blocks PHP)
lazyfree-lazy-expire yes # Expire keys asynchronously
hz 20 # Check expiry 20x/sec
# Enable persistence to survive restarts
save 900 1
save 300 10
appendonly yes
appendfsync everysec
The allkeys-lru policy is crucial: when memory fills up, Redis automatically evicts the least-used entries first — effectively garbage-collecting fragmented keys that are never reused.
Fix 4: Clean Up Stale Transients
WordPress plugins often set transients with no TTL (set_transient( $key, $value, 0 )). These live forever in Redis and accumulate over time. Use WP-CLI to audit and purge:
# Delete all expired transients
wp transient delete --expired --allow-root
# Count transients by prefix (find fragmentation sources)
wp db query "
SELECT LEFT(option_name, 50) as prefix, COUNT(*) as count
FROM wp_options
WHERE option_name LIKE '_transient_%'
GROUP BY LEFT(option_name, 50)
ORDER BY count DESC
LIMIT 20;
" --allow-root
Fix 5: Cache Warming After Purge
After a cache purge, there is a cold-start period where every request hits PHP simultaneously — the “thundering herd” problem. Pre-populate the cache before traffic hits:
# Crawl sitemap to warm critical pages after purge
curl -s https://yoursite.com/sitemap.xml | \
grep -oP "(?<=)[^<]+" | \
while read url; do
curl -s -o /dev/null -w "%{url_effective} -> %{http_code} (%{time_total}s)\n" "$url"
done
Monitoring: Cache Health Metrics
| Metric | Good | Warning | Critical |
|---|---|---|---|
| Redis hit rate | >90% | 70–90% | <70% |
| Page cache hit rate | >85% | 60–85% | <60% |
| Redis memory used | <70% | 70–90% | >90% |
| Evicted keys/sec | <10 | 10–100 | >100 |
| Unique cache keys | Stable | Growing | Unbounded |
# Quick health snapshot
redis-cli info | grep -E "used_memory_human|keyspace_hits|keyspace_misses|evicted_keys|db[0-9]:"
# Hit rate calculation
HITS=$(redis-cli info stats | grep keyspace_hits | cut -d: -f2 | tr -d "\r")
MISSES=$(redis-cli info stats | grep keyspace_misses | cut -d: -f2 | tr -d "\r")
TOTAL=$((HITS + MISSES))
[ $TOTAL -gt 0 ] && echo "Redis hit rate: $(( HITS * 100 / TOTAL ))%"
Real-World Results: WooCommerce Store with 12,000 Products
Results from applying all fixes above to a WooCommerce store with active UTM marketing campaigns:
| Metric | Before | After |
|---|---|---|
| Redis hit rate | 31% | 94% |
| Page cache hit rate (CDN) | 38% | 89% |
| Unique Redis keys | 2.1M | 48K |
| TTFB p50 | 620ms | 42ms |
| TTFB p95 | 1.8s | 110ms |
| PHP requests/min | 4,200 | 480 |
| Redis memory used | 4.2GB (OOM risk) | 380MB |
The biggest single improvement came from stripping UTM parameters at the Cloudflare Worker layer — that one change moved hit rate from 31% to 71% in under an hour.
Priority Fix Checklist
| Fix | Effort | Impact |
|---|---|---|
| Strip tracking params at CDN/NGINX | Low | Very High |
| Comprehensive exclusion rules | Low | High |
| Redis allkeys-lru + maxmemory limit | Low | High |
| Delete expired transients (WP-CLI) | Low | Medium |
| Cache warming after purge | Medium | Medium |
| AJAX-based sorting (WooCommerce) | High | Medium |