Caching

Cache Invalidation Strategies for AI-Generated Content

⏱ 15 min

The previous lesson built a cache keyed on a hash of the prompt. That solves one problem and creates another: if the source content changes but you keep asking the same question about it, in this case “summarize this post,” your hash key doesn’t change either, since the prompt text you send is built from the post’s title and content at generation time. Re-hash the new content and you get a new key, which means the old cached response for the previous version just sits there, valid by its own TTL, wrong for what the post actually says now. This lesson is about tying invalidation to the moment content actually changes, not just letting a TTL eventually catch up to reality.

What you'll learn in this lesson
Why TTL alone is not enough for edited content
A cache that expires in an hour is still wrong for the fifty-nine minutes before that.
Invalidating on save_post
Clearing the AI cache tied to a post the moment its content actually changes.
Post-scoped cache key mapping
Tracking which cache keys belong to which post, so save_post knows what to clear.
TTL-based expiry for content that ages on its own
Where a fixed expiry is the right tool, separate from content-change invalidation.
Prerequisites

The caching wrapper and hash-based keying from the previous lesson. This lesson adds an invalidation layer on top of it rather than replacing the caching mechanism itself.

Step 1: TTL alone silently serves stale output after an edit

A cache with a one-hour TTL feels safe until you notice what it actually guarantees: content edited at minute one is served through its old, cached AI summary until minute sixty, no matter how wrong that summary has become. For a typo fix, that’s harmless. For a correction to a factual claim, a price, or a safety instruction, an hour of a wrong AI-generated summary sitting on a live page is a real problem, not a cosmetic one.

A TTL is a ceiling on staleness, not a promise of correctness

Treat a TTL as “how long we’re willing to be wrong for at most,” never as “how fresh this is.” If a piece of content can change and that change matters, invalidate on the actual change event. Reserve the TTL for content that ages on its own even when nothing is edited, covered in Step 4.

Step 2: Track which cache keys belong to which post

save_post fires with a post ID, but the cache key from the previous lesson is a hash with no post ID embedded in it. To invalidate on save, you need a second, small mapping from post ID to the cache keys generated from it:

// File: my-plugin/class-ai-cache.php
function my_plugin_remember_ai_cache_key_for_post( int $post_id, string $cache_key ): void {
	$known_keys   = get_post_meta( $post_id, '_ai_cache_keys', true );
	$known_keys   = is_array( $known_keys ) ? $known_keys : array();
	$known_keys[] = $cache_key;

	update_post_meta( $post_id, '_ai_cache_keys', array_unique( $known_keys ) );
}

Call this alongside my_plugin_set_cached_ai_response() from the previous lesson, every time you generate and cache an AI response that’s derived from a specific post’s content:

// File: my-plugin/class-post-summarizer.php
function my_plugin_get_post_summary( int $post_id ) {
	$post   = get_post( $post_id );
	$prompt = 'Summarize in two sentences: ' . $post->post_title . "\n\n" . $post->post_content;
	$system = 'You are a concise editorial summarizer.';

	$key    = my_plugin_ai_cache_key( $prompt, $system );
	$cached = my_plugin_get_cached_ai_response( $key );

	if ( null !== $cached ) {
		return $cached;
	}

	$result = wp_ai_client_prompt( $prompt )
		->using_system_instruction( $system )
		->generate_text();

	if ( is_wp_error( $result ) ) {
		return $result;
	}

	my_plugin_set_cached_ai_response( $key, $result, DAY_IN_SECONDS );
	my_plugin_remember_ai_cache_key_for_post( $post_id, $key );

	return $result;
}

Step 3: Clear the known keys when the post actually changes

save_post fires on every save, including autosaves and revisions, so guard it, then clear every cache key this post has ever produced:

// File: my-plugin.php
add_action( 'save_post', function ( int $post_id, WP_Post $post ) {
	if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
		return;
	}

	$known_keys = get_post_meta( $post_id, '_ai_cache_keys', true );

	if ( ! is_array( $known_keys ) ) {
		return;
	}

	foreach ( $known_keys as $cache_key ) {
		delete_transient( $cache_key );
	}

	delete_post_meta( $post_id, '_ai_cache_keys' );
}, 10, 2 );

The next call to my_plugin_get_post_summary() after a save finds nothing cached, regenerates against the edited content, and rebuilds the key mapping from scratch. The one-hash-per-version property from the previous lesson means you never serve one version’s summary for another version, this hook just makes sure the old version’s cached entry doesn’t linger unused in storage after it’s no longer reachable.

If you're on a persistent object cache, group flushing is simpler

On a site with a real persistent object cache backend, storing all of a post’s AI responses under one cache group (for example 'ai-summaries-post-' . $post_id) and calling wp_cache_flush_group() on save_post avoids maintaining the key-list meta field entirely. This requires a backend that supports group flushing; without one, the key-tracking approach above works on plain transients everywhere.

Step 4: TTL is still the right tool for content that ages without being edited

Not everything that goes stale gets edited first. A summary of “current pricing” built from a page nobody has touched in months can still be wrong if pricing changed elsewhere and this page just wasn’t updated. For content like this, a TTL is a deliberate decision, not a fallback:

// File: my-plugin/class-post-summarizer.php
$ttl = has_term( 'evergreen', 'category', $post_id ) ? WEEK_IN_SECONDS : DAY_IN_SECONDS;
my_plugin_set_cached_ai_response( $key, $result, $ttl );

Shorter TTLs for content categories that tend to drift out of date without a corresponding edit, longer TTLs for content that’s genuinely stable once written. The save_post invalidation from Step 3 and a TTL are not competing strategies, they cover two different kinds of staleness: content that changed and content that just aged.

Test it

Generate and cache a summary, edit the post, and confirm the cache is gone before the TTL would have expired it on its own:

wp-env run cli wp eval '
$post_id = 123;
echo "Before edit: " . my_plugin_get_post_summary( $post_id ) . "\n";

wp_update_post( array( "ID" => $post_id, "post_content" => "Completely different content." ) );

$known = get_post_meta( $post_id, "_ai_cache_keys", true );
var_dump( $known ); // should be empty/false right after save_post ran
echo "After edit: " . my_plugin_get_post_summary( $post_id ) . "\n";
'

The “after edit” summary should reflect the new content, confirming the save hook cleared the stale entry rather than waiting out its TTL.

Recap

A TTL alone bounds how long a stale AI response can live, it doesn’t prevent serving it during that window. Track which cache keys a piece of content has produced, clear them on save_post (guarding against autosaves and revisions), and keep TTL-based expiry as a separate, deliberate tool for content that ages without being edited. Together, these two mechanisms cover both ways AI-generated content actually goes stale.

Resources & further reading

← Caching AI Responses Building a Cost-Monitoring Dashboard →