info

Affiliate Disclosure: This article contains benchmarked tools that may compensate us. We only recommend hardware and software tested in our lab environments for maximum LCP and INP efficiency.

Solving the Cache Fragmentation Problem in WordPress

Learn how to synchronize your application cache with CDN edge layers using modern purge protocols.

calendar_today folder

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

MetricGoodWarningCritical
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<1010–100>100
Unique cache keysStableGrowingUnbounded
# 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:

MetricBeforeAfter
Redis hit rate31%94%
Page cache hit rate (CDN)38%89%
Unique Redis keys2.1M48K
TTFB p50620ms42ms
TTFB p951.8s110ms
PHP requests/min4,200480
Redis memory used4.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

FixEffortImpact
Strip tracking params at CDN/NGINXLowVery High
Comprehensive exclusion rulesLowHigh
Redis allkeys-lru + maxmemory limitLowHigh
Delete expired transients (WP-CLI)LowMedium
Cache warming after purgeMediumMedium
AJAX-based sorting (WooCommerce)HighMedium