If you’d asked me last week whether the vacation search on those sites was cached, I’d have said yes and shown you the rows in the database to prove it. They were right there, with sensible expiry times.
Nothing ever read them.
The symptom nobody reported
A handful of travel sites run a tour search widget. You land on the page, it asks a third-party travel API for the search criteria — which months have departures, which destinations, which suppliers — and fills the dropdowns. Pick a destination and it asks again with a filter, so the other dropdowns narrow down.
That criteria call is slow. Five to eight seconds cold on a good day, and I’ve seen the same endpoint take thirty for a different network. Which is exactly why the result goes into a transient for a day.
Except the page load always took about six seconds. So did hitting Reset. So did choosing “All Destinations”. It never struck anyone as a bug, me included, because a travel search that takes a moment to think feels normal. It’s a travel search. They’re all a bit like that.
It came to light the week I was in that plugin building something else for the same sites. I wasn’t looking for it.
Two lines, one key
The plugin has a little helper that every cached API call goes through on the way out. It takes a type and an optional sub-key, glues them into a transient name, and stores the payload. Simplified and renamed, it looks like this:
function myapi_set_cached( $type, $sub = '', $data = null, $period = DAY_IN_SECONDS ) {
$name = 'myapi_' . $type;
$name .= empty( $sub ) ? '' : '_' . $sub;
set_site_transient( $name, $data, $period );
return $data;
}
Note what it does with an empty sub-key. No trailing underscore. Tidy.
The AJAX handler that serves the search criteria didn’t use a matching helper on the way in. It built the name itself:
$transName = 'myapi_tour_data_' . $agencyID . $filterName; $data = get_site_transient( $transName );
When there’s an agency ID or a filter, both sides produce the same string and the cache hits. When there’s neither — which is the unfiltered, default-agency case, which is the page load, which is Reset, which is “All Destinations” — the writer stores myapi_tour_data and the reader asks for myapi_tour_data_.
One underscore. Six seconds. Every visitor, every time they started a search from scratch.
The line that builds the reader’s key goes back to 2021 in the history. I’m not going to pretend I know how long the two have actually disagreed, because the helper has been tidied up since then and I didn’t dig through every version of it. Long enough.
Then I checked the sibling
There’s a second plugin that talks to the same API for a different network of sites. It was forked from the first one years ago and they’ve drifted, the way forks do. Same helper, same criteria handler, so I went and looked.
Different bug. Opposite direction.
This one passed the filter name with its leading underscore already attached as the sub-key:
$filterName = is_null( $filter ) ? '' : '_' . md5( $filter ); // writer: the helper adds its own '_' on top myapi_set_cached( 'tour_data', $filterName, $data ); // -> myapi_tour_data__3f2a... // reader $transName = 'myapi_tour_data' . $filterName; // -> myapi_tour_data_3f2a...
So here the unfiltered case matched fine and every filtered lookup missed, on account of a double underscore. Which would have mattered, except that the read in that plugin had been commented out entirely at some point, with no note as to why. It fetched from the API every single time and dutifully wrote a transient nobody was ever going to ask for.
There had also been an earlier fix in the first plugin with the same shape: reads going through get_transient() while the writes used set_site_transient() on multisite. Different function, same outcome. At that point it stops being a typo and starts being a pattern.
Why this hides so well
Every other kind of cache bug has a tell. Stale data, wrong data, a user seeing someone else’s cart. This one has nothing. The page is correct. It’s just slow, in a place where slow is expected.
And the usual way you’d check — “is it caching?” — gives you the wrong answer. You look in wp_sitemeta or your object cache, you see the entries, they have sensible expiry times, and you move on. The write side is working perfectly. A cache entry existing tells you the writer ran. It tells you nothing about whether the reader is asking for the same name.
The only proof a cache works is a hit.
The fix
The actual repair is boring, which is how you want it. One function builds the key, and both the reader and the writer call it:
function myapi_tour_criteria_key( $agencyID, $rawFilter = array() ) {
$filters = myapi_parse_filters( $rawFilter );
$sub = $agencyID . ( is_null( $filters ) ? '' : '_' . md5( $filters ) );
return array(
'filters' => $filters,
'transName' => 'myapi_tour_data' . ( $sub === '' ? '' : '_' . $sub ),
);
}
The AJAX handler asks for the key, tries the cache, and only calls the API on a miss, passing that same name down so the write lands where the next read will look. In the forked plugin the commented-out read came back to life too.
A couple of other things got fixed while I had the file open. The fork’s cache is now network-wide, because the criteria only depend on the filter and the network’s API key, so there’s no reason for every subsite to pay for the same six-second call. And API errors no longer get cached. A filter combination with no results comes back as a 400, and the old code would happily store a day’s worth of nulls for it.
And then warm it
Fixing the key got the second visitor a fast page. The first visitor each day was still going to wait. So there’s now a daily cron job that fetches the unfiltered criteria and then every destination on the list, before anyone asks.
A few details in there that I’d reuse:
- It only runs on sites that actually publish the search widget. No point warming a cache on a site that’ll never read it (he says, a few paragraphs after describing exactly that).
- The warmed entries live for two days, so they can’t lapse between daily runs even if one runs late.
- Each run has a work budget of 150 seconds. A cron request that runs long enough to hit PHP’s execution limit dies halfway, so it works through the queue until the budget’s gone and then schedules itself to carry on.
- It books the follow-up run before doing any work, then replaces it at the end. If the request is killed mid-way by the web server, the queue still resumes.
- It saves the shortened queue before processing each destination, not after. If one destination makes the API fall over, the next run doesn’t start on that same destination again forever.
- Entries warmed in the last 20 hours get skipped, which matters on multisite where several subsites share an agency. The freshness check reads the transient’s timeout option and subtracts the TTL, since WordPress doesn’t store a write time.
function myapi_prewarm_is_fresh( $transName ) {
$timeout = get_site_option( '_site_transient_timeout_' . $transName );
if ( ! $timeout ) {
return false;
}
$writtenAt = (int) $timeout - MYAPI_PREWARM_TTL;
return $writtenAt > time() - MYAPI_PREWARM_FRESH;
}
Page load, Reset and “All Destinations” now come straight out of the cache. Filtered lookups too, for any destination the cron has been through.
The bit to take away
A cache key is a contract between two lines of code that usually live in different functions, sometimes in different files, and in this case in two plugins that used to be the same plugin. If the key gets built in two places, it will drift. Not might. Will. Build it once and make both sides ask for it.
And if you’ve got a cache you believe in, go and prove it with a hit rather than an entry. I had two of them that were, in the strictest sense, write-only storage.
