Technote

Performance optimization Advanced

Series The REST API and going headless Part 7 of 8

Caching API responses: which layer, and how to invalidate

Adding a cache is easy. Knowing when to discard it is the work — and getting that wrong can end with payment state that never updates.

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

Three cache layers — faster towards the top, harder to invalidate towards the top

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.

The front-cache boundary — miss the fourth line and payment state stops updating

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.

More on this topic

All technotes

Performance optimization Advanced

A front-end budget you can actually hold

Pages do not get heavy in one go. They get heavy through small, individually reasonable additions. A ceiling you actually check is what stops the accumulation.

Developers 8 min read

Performance optimization Advanced

A conversion audit: finding where people leave

Polishing a page nobody reaches is the most common waste. Walk the whole path end to end and the leak shows itself first.

Marketers 7 min read

Performance optimization Practical

The cost of pop-ups — search and conversion at once

An interstitial that covers the content on mobile is treated unfavourably in search, and the same interstitial costs conversions too. The levers are timing, trigger and dismissal.

Marketers 8 min read

₩270,000 · Join the program