Skip to content
Performance

Four Object Cache Bugs That Only Appear in Production

· · 8 min read
Timeline showing a cache delete before a database write letting a concurrent reader re-cache the old row, leaving it stale for the full TTL

Object caching is the cheapest performance win in WordPress and the easiest thing to get subtly wrong. The failure mode is not a crash. It is a member seeing a reply count that is thirty seconds behind, or a space that looks public for five minutes after being made private, and nobody able to reproduce it on staging.

These are four bugs we hit building Jetonomy’s caching layer, all of which share a property: they do not exist without a persistent object cache. Local development on the default transient-backed cache will not show you any of them. They appear when the site moves to Redis.

Bug 1: busting the cache before the write

The instinct is to clear the stale value first, then write the new one. It reads naturally:

// Wrong.
Cache::delete( "space:{$id}" );
$wpdb->update( $table, $data, [ 'id' => $id ] );

There is a window between those two lines. A concurrent read landing in that window finds no cache entry, queries the database, gets the old row because the write has not committed, and caches it.

The cache is now primed with stale data, and it stays stale for the full TTL – five minutes by default. The write succeeded. The database is correct. Every reader sees the old value.

The fix is one line moved:

// Right: bust after the write completes.
$wpdb->update( $table, $data, [ 'id' => $id ] );
Cache::delete( "space:{$id}" );

Now the worst case is a read that gets the old value microseconds before the bust, which self-corrects on the next request. That is a different class of problem from a cache poisoned for five minutes.

The rule we settled on: bust the exact keys whose value changed, immediately after the database write completes, never before.

This bug is close to invisible in testing because it needs concurrency. One developer clicking through a staging site will never see it. A community with a hundred people online will produce it several times an hour.

Bug 2: cached null comes back as an empty string

This one cost us real time and is worth knowing before you meet it.

Standard cache-aside looks like this:

Cache::remember( "space:{$id}", fn() => Space::query( $id ) );

If the callback legitimately returns null – the row does not exist – that null gets cached. Fine so far.

Some persistent object cache backends materialise a cached null as '' on the next read. Redis and Memcached both do it in common configurations. You stored null, you read back an empty string.

Which means a function with a typed return contract:

public static function find( int $id ): ?object

now returns '' and fatals for every caller downstream. Intermittently, only on production, only after something has cached a miss.

The fix is a variant that coerces anything non-object back to null:

Cache::remember_object( "space:{$id}", fn() => Space::query( $id ) );

The general lesson beyond this specific API: the object cache is not type-safe. What you put in is not guaranteed to be what you get out, particularly for falsy values. If you have a typed return and you cache misses, validate the type on read rather than trusting the round trip.

Bug 3: one row, two keys, one bust

A space row is served under two cache keys. space:{id} holds the row. space:slug:{slug} holds the slug-to-id mapping used by findbyslug().

Update the row, bust space:{id}, and the slug mapping is still there. Usually harmless – until somebody renames a space, at which point the old slug still maps to the id and the new slug has no entry.

public static function bust_cache( int $id, ?string $slug = null ): void {
    $keys = [ "space:{$id}" ];
    if ( ! empty( $slug ) ) {
        $keys[] = "space:slug:{$slug}";
    }
    Cache::delete_many( $keys );
}

delete_many() exists precisely for this – one call, several keys, for writers that change a value served under more than one key.

The detail worth copying is that a rename busts both the old and the new slug. The old mapping has to go or it keeps resolving; the new one has to go in case something already cached a miss for it.

And notice which writes need the slug and which do not. A rename does. A reply-count increment does not, because the slug mapping did not change. Busting more than necessary is safe but wasteful, and being deliberate about it is the difference between a cache that helps and one that thrashes.

Bug 4: invalidating from a hook listener

This is the architectural one, and the most likely to be in your codebase right now.

The tidy-looking approach is to listen for an action and invalidate there:

// Looks clean. Misses callers.
add_action( 'my_plugin_space_updated', function ( $id ) {
    Cache::delete( "space:{$id}" );
} );

The problem is that this only fires for code paths that remember to fire the action. In a plugin of any size, the callers that mutate a row include:

  • The admin UI
  • REST endpoints
  • AJAX handlers
  • WP-CLI commands
  • Import routines
  • Abilities API callers

Every one of those is a place somebody can write to the database without firing your action – and the newer entry points are exactly the ones added by a developer who did not know the listener existed.

The invalidation belongs inside the model method, not in a listener:

public static function update( int $id, array $data ): bool {
    $old_slug = ( parent::find( $id )->slug ?? null );
    $ok = parent::update( $id, $data );

    if ( $ok ) {
        self::bust_cache( $id, $old_slug );
        if ( ! empty( $data['slug'] ) && $data['slug'] !== $old_slug ) {
            self::bust_cache( $id, $data['slug'] );
        }
    }

    return $ok;
}

Now every caller gets correct invalidation for free, because they all go through the model. A new REST route added next year inherits it without its author knowing the cache exists.

This is the same discipline as denormalised counters: one write path, and the bookkeeping lives inside it. Anything that relies on every caller remembering to do something will eventually meet a caller who did not.

The rule about set-based updates

A related trap, worth its own paragraph.

UPDATE wp_jt_spaces SET is_private = 1 WHERE category_id = 7;

That statement changes an unknown number of rows and names none of them. There is no id to bust. If your invalidation is per-id – and it should be – a set-based update silently skips it entirely.

Two acceptable answers. Enumerate the affected ids first and bust each one, which is correct but costs a query. Or call a group flush, which is blunt but honest.

Flushing is defensible for one-shot admin, CLI and import paths whose set-based writes genuinely cannot name the rows cheaply. It is not defensible on a per-write path, because you are throwing away the entire cache for every member to fix one row.

Cache::flush(): void

Ours is guarded by wpcachesupports( 'flushgroup' ), falls back to a full wpcacheflush() on a drop-in without group support, and is a no-op with no persistent cache. That guarding matters – a naive wpcache_flush() on a shared Redis instance clears cache belonging to other sites.

One wrapper, one group

Underneath all four of these is a structural decision worth stating.

Every cached read and write in the plugin routes through a single wrapper class, in one cache group, with one default TTL. The rule for contributors is explicit: do not add per-service wpcache* calls, and do not create a second cache group. Extend the wrapper.

The reason is that everything above becomes impossible to enforce once cache calls are scattered. You cannot audit invalidation if reads and writes happen in forty files. You cannot change the TTL without grepping. You cannot add the remember_object() null coercion in one place. And a group flush stops being meaningful if half your data is in a different group.

This is not a performance argument. A single wrapper is not faster. It is a maintainability argument, and specifically it is what makes the invalidation rules auditable – you can read one file and know how caching works.

How to check your own

If you run WordPress sites with a persistent object cache, these are worth grepping for.

Busts before writes. Search for wpcachedelete and check what is on the line after it. If the database write follows the delete, you have bug 1.

Cached nulls with typed returns. Look for any cache-aside helper whose callback can return null, feeding a function with a ?type return. That is bug 2 waiting for a cache miss.

Listener-based invalidation. Search for wpcachedelete inside add_action callbacks. Each one is a bet that every writer fires that action.

Scattered cache groups. grep -r "wpcacheset" | grep -o "'[a-z_]*'" | sort -u will show how many groups a plugin uses. More than one or two usually means nobody is in charge of invalidation.

For diagnosing a live site, our object cache guide covers verifying the cache is actually doing what you think, which is the prerequisite for any of this mattering.

Why staging never catches these

Worth being explicit about, because it explains why competent teams ship all four.

EnvironmentCache backendWhich bugs appear
Local devNone (in-memory per request)None
Staging, no RedisNoneNone
Staging, Redis, one testerPersistentBug 2 only
Production, Redis, real trafficPersistent + concurrentAll four

Without a persistent cache, every request starts with an empty cache. Bust order does not matter because nothing survives between requests. Cached nulls do not matter because nothing is cached long enough to be read back. Missing invalidation does not matter because the data expires at the end of the request anyway.

That is why “it works locally” is not evidence here, and why the first three of these bugs are effectively invisible until a site has both Redis and simultaneous users.

The practical takeaway: if production runs a persistent object cache, staging must too. Not a smaller one, not an optional one – the same class of backend. Otherwise your staging environment is testing a different caching model from the one your members use, and the bugs that matter are exactly the ones it cannot produce.

A cheap partial mitigation if you cannot run Redis on staging: exercise the write paths twice in a row and check the second read. Most bust-ordering and cached-null problems will surface on the second request if anything is persisting at all.

The pattern behind all four

Every bug here is the same shape: the cache and the database disagreed, and the code did not have a single place responsible for reconciling them.

Busting before the write is a timing failure. Cached nulls are a type failure. Two keys for one row is a completeness failure. Listener-based invalidation is a coverage failure. All four disappear when invalidation is a named method on the model, called by the write itself, in a single cache layer you can read in one sitting.

That is the whole architectural argument, and it is worth more than any individual fix. Caching is easy to add and hard to keep correct, and the thing that keeps it correct is not cleverness – it is having exactly one place where the rules live.

Worth adding this to your safe update workflow too: an object cache is one of the components most likely to behave differently between staging and production, so a change that tests clean locally deserves a second look on a site that actually has Redis behind it.