On a WooCommerce store with 80,000 products, the "My Account" page took 1.4 seconds because every request ran the same 140 queries against wp_options and wp_postmeta. Enabling Redis as a persistent object cache took it to 210 ms with no code changes. That was the easy win; the harder part was caching correctly for the rest of the system, including a Node.js API that fronted the same database. This post covers all four caching layers I use, with the configuration for both stacks.

The four layers

Request ─▶ [ Edge / CDN ] full HTML for anonymous users TTL: hours key: URL │ miss ▼ [ Full-page cache ] Nginx FastCGI or app-level TTL: hours key: URL + cookie state │ miss / bypass (logged in) ▼ [ Fragment cache ] expensive partials: menus, TTL: minutes key: fragment + inputs │ product grids, related items ▼ [ Object cache ] query results, options, meta, TTL: request+ key: table:id / group:key │ API responses ▼ [ Database ]

The edge and full-page layers are covered in Cloudflare Workers and Nginx tuning. This post is about the two layers that Redis owns: object and fragment caches, plus the application caches (sessions, rate limits, queues) that share the same instance.

WordPress: the persistent object cache

WordPress has an in-memory object cache that lives for one request. Without a persistent backend, every request rebuilds it: autoloaded options, user meta, term relationships, transients. A Redis drop-in makes that cache survive between requests.

# Server: Redis with a memory cap and LRU eviction (never let it grow unbounded)
sudo apt install redis-server
sudo tee -a /etc/redis/redis.conf <<'EOF'
maxmemory 512mb
maxmemory-policy allkeys-lru
save ""                       # no RDB snapshots for a pure cache (enable AOF if you also run queues here)
EOF
sudo systemctl restart redis-server

# WordPress: the Redis Object Cache plugin + drop-in
wp plugin install redis-cache --activate
wp redis enable
wp redis status
<?php
// wp-config.php
define('WP_REDIS_HOST', '127.0.0.1');            // or a Unix socket: '/var/run/redis/redis.sock'
define('WP_REDIS_PORT', 6379);
define('WP_REDIS_DATABASE', 0);
define('WP_REDIS_PREFIX', 'clientsite:');          // REQUIRED when several sites share one Redis
define('WP_REDIS_MAXTTL', 86400);                  // keys expire even if nobody invalidates them
define('WP_REDIS_TIMEOUT', 1);
define('WP_REDIS_READ_TIMEOUT', 1);
define('WP_REDIS_IGNORED_GROUPS', ['counts', 'plugins', 'themes']);   // groups that change too often to be worth caching
define('WP_CACHE_KEY_SALT', 'clientsite');

WP_REDIS_PREFIX is the setting people forget on shared hosting, and the result is one site serving another site's options. The MAXTTL is the safety net for the plugins that write to the cache and never invalidate.

Measuring the effect

<?php
// Drop into a mu-plugin temporarily, load a page, read the footer comment
add_action('wp_footer', function (): void {
    global $wp_object_cache, $wpdb;
    printf("<!-- queries: %d | time: %.3fs | cache hits: %d misses: %d -->",
        $wpdb->num_queries, timer_stop(0, 3), $wp_object_cache->cache_hits, $wp_object_cache->cache_misses);
});
// Store "My Account" page: before  queries: 142 | time: 1.38s
//                          after   queries: 11  | time: 0.21s  | hits: 1,204 misses: 9

WordPress: fragment caching the expensive parts

The object cache stores query results, but a mega-menu that walks 300 terms or a "related products" block that runs a similarity query still costs PHP time to assemble. Cache the rendered HTML fragment, keyed by its inputs, and invalidate when those inputs change:

<?php
// inc/fragment-cache.php
function umm_fragment(string $key, int $ttl, callable $render, array $tags = []): string {
    $group = 'umm_fragments';
    $html = wp_cache_get($key, $group);
    if ($html !== false) return $html;

    ob_start();
    $render();
    $html = (string) ob_get_clean();

    wp_cache_set($key, $html, $group, $ttl);
    foreach ($tags as $tag) {                                   // tag → keys index, for targeted invalidation
        $keys = wp_cache_get("tag:{$tag}", $group) ?: [];
        $keys[$key] = true;
        wp_cache_set("tag:{$tag}", $keys, $group, 0);
    }
    return $html;
}

function umm_fragment_purge_tag(string $tag): void {
    $group = 'umm_fragments';
    foreach (array_keys(wp_cache_get("tag:{$tag}", $group) ?: []) as $key) wp_cache_delete($key, $group);
    wp_cache_delete("tag:{$tag}", $group);
}

// Usage in the header template
echo umm_fragment('mega-menu:' . get_locale(), 3600, fn() => get_template_part('template-parts/mega-menu'), ['menus', 'terms']);

// Usage on a product page: related products depend on the product and its categories
echo umm_fragment("related:{$product_id}", 900, fn() => woocommerce_related_products(), ["product:{$product_id}", 'products']);

// Invalidation hooks
add_action('wp_update_nav_menu', fn() => umm_fragment_purge_tag('menus'));
add_action('edited_term',        fn() => umm_fragment_purge_tag('terms'));
add_action('woocommerce_update_product', fn(int $id) => umm_fragment_purge_tag("product:{$id}"));

On the store, fragment-caching the mega-menu and related-products block took the logged-in product page from 210 ms to 95 ms. Because it goes through wp_cache_*, the same code works with any object cache backend and silently becomes a per-request cache if Redis is unavailable.

Node.js: cache-aside with stampede protection

The Node API in front of the same database used a helper I now copy between projects. It handles the three things naive caching gets wrong: stampedes on expiry, stale-while-revalidate, and failing safe when Redis is down. The full version with locking is in System Design Fundamentals; here is the production wrapper around it with tag invalidation:

// lib/cache.ts
import IORedis from 'ioredis';
export const redis = new IORedis(process.env.REDIS_URL!, { maxRetriesPerRequest: 1, connectTimeout: 500, commandTimeout: 100 });
redis.on('error', () => {});                                       // never crash on Redis errors

interface Opts { ttl: number; stale?: number; tags?: string[] }

export async function cached<T>(key: string, opts: Opts, loader: () => Promise<T>): Promise<T> {
  const k = `c:${key}`;
  let raw: string | null = null;
  try { raw = await redis.get(k); } catch { /* Redis down: fall through */ }

  if (raw) {
    const { v, exp } = JSON.parse(raw) as { v: T; exp: number };
    if (Date.now() < exp) return v;
    void refresh(k, opts, loader).catch(() => {});                  // stale: return now, refresh in background
    return v;
  }
  return refresh(k, opts, loader);
}

async function refresh<T>(k: string, { ttl, stale = 60, tags = [] }: Opts, loader: () => Promise<T>): Promise<T> {
  const lock = await redis.set(`${k}:lock`, '1', 'PX', 5000, 'NX').catch(() => 'OK');   // if Redis is down, everyone loads
  if (!lock) { await new Promise(r => setTimeout(r, 40)); const again = await redis.get(k).catch(() => null); if (again) return JSON.parse(again).v; }
  const v = await loader();
  try {
    const pipe = redis.pipeline();
    pipe.set(k, JSON.stringify({ v, exp: Date.now() + ttl * 1000 }), 'EX', ttl + stale);
    for (const t of tags) pipe.sadd(`tag:${t}`, k).expire(`tag:${t}`, ttl + stale + 60);
    pipe.del(`${k}:lock`);
    await pipe.exec();
  } catch { /* cache write failed; the value is still correct */ }
  return v;
}

export async function invalidateTag(tag: string) {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length) await redis.del(...keys, `tag:${tag}`);
}

// Usage
const product = await cached(`product:${id}`, { ttl: 600, tags: [`product:${id}`, 'products'] }, () => repo.product(id));
const grid    = await cached(`grid:${categoryId}:${page}`, { ttl: 120, tags: ['products', `category:${categoryId}`] }, () => repo.grid(categoryId, page));

// On product update (from the same transaction outbox that feeds the queue):
await invalidateTag(`product:${id}`);
await invalidateTag('products');       // lists that include it

Two details matter. commandTimeout: 100 means a slow Redis costs at most 100 ms before the code falls back to the loader; without it, a Redis stall becomes a site stall. And redis.on('error', () => {}) is not laziness: ioredis emits errors as events, and an unhandled error event terminates the process.

Application caches on the same Redis

UseKey shapeTTLNotes
Sessionssess:{id}Sliding, 7-30 daysHash per session; EXPIRE on each touch (see auth guide)
Rate limitsrl:{user|ip}SecondsLua token bucket; must be atomic
Idempotency keysidem:{user}:{key}24 hStore status + body (REST API guide)
QueuesBullMQ-managedUntil processedSeparate instance with AOF; never mix with an LRU-evicting cache (why)
Embedding cacheemb:{hash(text)}30 daysSaves real money on semantic search queries
Locks / leader electionlock:{resource}SecondsSET NX PX; release only if you still own it (Lua compare-and-delete)

The queue row is the one that bites: if your queue and your cache share a Redis with allkeys-lru, the eviction policy will eventually delete a job. Run two instances (or two managed databases), one allkeys-lru with no persistence for cache, one noeviction with AOF for queues and sessions.

Invalidation strategy

  1. TTL on everything. Even with perfect invalidation, a TTL bounds the damage of a bug.
  2. Tags for related keys. "Product 42 changed" should purge the product, the grids that include it and the search results that mention it, without touching anything else.
  3. Invalidate from the write path, ideally from the same transaction outbox that publishes events, so a rolled-back write does not purge a valid cache.
  4. Never FLUSHALL in production. A cold cache on a busy site sends every request to the database at once. If you must reset, delete by prefix with SCAN over a few minutes.
  5. Versioned keys for deploys that change the cached shape: c:v3:product:42. Old keys expire on their own.

Monitoring

redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys|expired_keys'
redis-cli INFO memory | grep -E 'used_memory_human|maxmemory_human|mem_fragmentation_ratio'
redis-cli --bigkeys                # find the 4 MB menu fragment someone cached
redis-cli SLOWLOG GET 10           # commands over 10 ms (slowlog-log-slower-than)

Alert when the hit rate drops below ~85% (something is invalidating too aggressively or keys are being evicted), when evicted_keys climbs (raise maxmemory or lower TTLs), and when fragmentation exceeds 1.5 (restart during a quiet hour or enable activedefrag).

Numbers from the store
PageBeforeObject cache+ Fragments
My Account (logged in)1.38 s0.21 s0.14 s
Product (logged in, cart)0.92 s0.31 s0.095 s
Category grid (Node API, p95)340 ms28 ms (cache-aside, 96% hit)
DB queries/day41 M6.2 M4.1 M

Caching is the first of the three building blocks in my system design fundamentals and the one with the best effort-to-result ratio on almost every project I touch. The order of operations is always the same: object cache first (free, safe), fragments for the pieces that are still slow, and full-page or edge caching for anonymous traffic on top. Get those three in place and most "we need a bigger server" conversations end.