When an API feels slow, the first instinct is to add a cache. Adding one is easy. The work is deciding when to throw it away, and getting that wrong produces something worse than slow — fast, confident, wrong data.
Three layers doing different jobs
The object cache does not cache responses. It caches the pieces a response is built from — posts, meta, terms — removing duplicate queries inside a request, and with Redis attached it persists between them. Core handles invalidation, which makes it the safest layer.
Transients are a named result you computed. They suit things like an external API call or a heavy aggregate, where “ten minutes stale is fine” is a defensible statement.
The page or HTTP cache returns whole responses from in front of the application. It is the fastest, and because PHP never runs, your code cannot participate in invalidation at all. So for that layer, decide what must never go in before deciding what may.
Do not invalidate by enumeration
The finer your cache keys, the more of them you must delete when something changes. If a listing is cached per sort order, page and filter combination, nobody can say with confidence which keys a single edited post invalidates. The answer is not to delete them: put a generation number in the key and bump the number instead.
function wper_tools_payload() {
$gen = (int) get_option( 'wper_tools_gen', 1 );
$key = 'tools:' . $gen . ':' . get_locale();
$hit = wp_cache_get( $key, 'wper_api' );
if ( false !== $hit ) {
return $hit;
}
$payload = wper_build_tools_payload(); // the expensive part
wp_cache_set( $key, $payload, 'wper_api', 10 * MINUTE_IN_SECONDS );
return $payload;
}
// Invalidation is bumping a generation, not deleting keys.
add_action( 'save_post_wper_tool', function () {
update_option( 'wper_tools_gen', (int) get_option( 'wper_tools_gen', 1 ) + 1, false );
} );
Once the generation moves, nothing looks up the old keys and they expire on their own. There is no key list to maintain, and invalidation is one line regardless of how many combinations exist.
get_locale() in the key is not incidental either. Every axis that changes the response has to be in the key — language, authentication state, context, query arguments. Miss one and somebody’s response is served to somebody else.
For the front layer, start with the exclusions
Core sends no-cache headers on REST responses for logged-in users. But there is no guarantee the layer in front respects them — an nginx FastCGI cache does exactly what its configuration says and nothing more. So write the bypass conditions out explicitly.
That fourth line is not theoretical. Cache a webhook route and the provider keeps receiving 200 while your database never changes: the logs look healthy and only the payments fail to register. It is the same point the previous part made, because this boundary sits in the same place whether you approach it from security or from performance.
Finally, the order of work. Cache after measuring. If the slowness is an N+1 query and you cache the response, every expiry serves that same slow request again. A cache does not replace the fix; it widens the reach of one you have already made.
Designing cache layers is covered further in the Performance archive, and getting the object cache and the page cache to stop contradicting each other is part of our optimization program.
Next part
One part left. With all of this in hand, the question that follows is “should we split the front end off entirely?”. We will look honestly at what headless gives you and what it takes away.