AI agents

Cache Invalidation Across Two Layers: A TTL Matrix

Antonio Evans10 min read

A two-column table on a whiteboard: left column lists data sources (user profile, product catalog, feature flags, session token), right column has two TTL cells per source labeled Local and Prefix.
Key points

Key points

  1. 1Set two TTLs per data source, one for the local in-process cache and one for the provider prefix cache, because the two layers expire on different clocks and for different reasons.
  2. 2Choose each TTL from how stale the data is allowed to be and how often the source changes, not from how often it is read alone.
  3. 3Log a served_by field plus the entry age on every response so you can tell whether a stale answer came from your local cache or from the provider's prefix cache.
  4. 4Use the layer field to catch invalidation drift: stale answers that come from the prefix layer usually mean the local TTL is too long relative to the prefix TTL.

How the two layers differ

A local cache and a provider prefix cache are not the same mechanism, and treating them as one is where most invalidation bugs start.

The provider layer caches the computed key-value state behind a repeated prompt prefix. The match is on the literal token sequence, so a single changed character before the cache boundary breaks the hit, as the Modular inference handbook explains. Providers then keep that entry for a fixed window. On the Claude prompt caching docs, the default lifetime is five minutes and it is refreshed at no cost each time the cached content is used, measured from the start of the request that writes or reads the entry rather than from the end of the response.

The local layer is yours. It holds the thing you actually send: a serialized system prompt, a retrieved document set, a tool manifest, a rendered context block. It is a key-value store with a TTL you pick, and it returns a full string rather than a KV state.

That difference matters for invalidation. When the provider entry expires, the next request just pays full prefill again. Nothing is wrong, it is only slower and more expensive. When your local entry goes stale, you can send an outdated document or an outdated instruction to the model, and the model will answer from it confidently.

So the local TTL governs correctness. The prefix TTL governs cost and latency. Your matrix has to respect that split.

PropertyLocal cacheProvider prefix cache
What is storedThe serialized context you sendComputed KV state for a token prefix
Who controls the TTLYouProvider policy or request parameter
Match ruleYour key (usually a hash or source ID)Exact token prefix, including whitespace
What a stale entry causesA wrong or outdated answerExtra prefill cost, not a wrong answer
How you observe itYour own countersUsage fields and response headers
Failure to watchSilent stalenessLow hit rate and higher bills

The exact-match requirement is strict. vLLM prefix caching hashes each block on the tokens in the block plus the tokens in the prefix before it, and only full blocks are cached. Furiosa's prefix caching docs describe the same idea as a radix tree that reuses the longest token-exact prefix and computes only the unmatched suffix. Neither layer will forgive a moved timestamp at the top of your prompt.

That is the first decision rule: anything that varies per request must live after the cache boundary, or your prefix hit rate collapses regardless of the TTL you set.

Building the TTL matrix by data source

The right unit of TTL is the data source, not the endpoint. A single request often assembles several sources into one context block: a system prompt, a product catalog, a customer record, a retrieved policy document, a live status feed. Each has its own tolerable staleness.

Start with two questions per source:

  1. If a user saw a version of this data that is N seconds old, would that be a support ticket?
  2. How often does the source actually change, in practice?

The first answer sets the ceiling on your local TTL. The second tells you whether a longer TTL buys you anything at all. A source that changes once a day does not need a thirty second TTL, and a source that changes every few seconds cannot be cached for an hour without someone noticing.

Then set the prefix TTL. The prefix TTL is bounded by the provider's policy and by how long the prefix stays byte-identical. A prefix that embeds a customer record changes whenever that record changes, so its practical TTL is the record's local TTL, not the provider's maximum.

Data sourceChange rateTolerable stalenessLocal TTLPrefix TTL
Static instructions and tool definitionsOnly on deployZero (versioned)Until deploy, keyed by versionLong, up to provider max
Policy and reference documentsOn editorial publishMinutesMinutes, plus explicit bust on publishMatches local TTL
Product catalogBatched updatesUnder an hour15 to 60 minutesMatches local TTL
Customer or account recordOn user writeSeconds1 to 30 seconds, plus bust on writeMatches local TTL
Inventory or priceContinuousSeconds5 to 15 secondsShort, or excluded from prefix
Session conversation historyEvery turnZeroUntil the turn endsGrows with the conversation
Live status or metrics feedContinuousZeroDo not cache, or 1 to 5 secondsNever in the prefix

Two rules fall out of the table.

First, the prefix TTL should never be shorter than the local TTL for the same source. If the local layer can serve content for 60 minutes but the prefix entry only survives five, you pay for a cache write every five minutes and never read it back cheaply. Caching has a write premium; if you pay it and then let the entry die unused, you have made things worse. The Digital Applied playbook on prompt caching puts the breakeven on the shortest tier at roughly 1.4 reads per cached write, so a source that is read fewer than twice per write window should not be cached there at all.

Second, the highly volatile sources should be pushed out of the prefix entirely. The Flexera prompt caching breakdown makes the same point about static versus dynamic content: caching rewards stability, and volatile data sitting early in the prompt destroys the hit for everything after it.

Add jitter to local TTLs that expire on a shared schedule. When many workers hold the same source with the same TTL, they all miss at once and hammer the upstream. WordPress VIP's write-up on cache stampedes shows the pattern and the fix: randomize expiration slightly so load spreads out.

A worked example with numbers

Take an agent that answers questions about a customer account. Its context has three parts, in this order:

  • A 1,200 token system prompt and tool manifest, stable between deploys.
  • A 3,000 token policy document set, republished a few times a day.
  • A 400 token customer record, changed whenever the customer edits their profile.

Requests arrive for the same customer in bursts. Say a burst is five requests within two minutes, and that burst pattern repeats for many customers through the day.

A naive setup caches the whole assembled context locally for 60 minutes and relies on the provider's default five minute prefix lifetime. What happens:

  • The local cache serves the burst from its first write. That part is fine.
  • The prefix cache is warm for the first request after each local write, and the four follow-ups within five minutes hit it. Good.
  • But the customer record changes to something else and the local key does not include a version, so the local layer keeps serving the old record for up to an hour. Users see stale account data and file tickets.
  • Because the local entry outlives the prefix entry, most later requests in the hour are local hits that still pay full prefill. The prefix layer contributes almost nothing.

Now the matrix-driven setup:

SourceLocal TTLPrefix
System prompt and toolsUntil deploy, keyed by a content hashCached, long lifetime; shared across all customers
Policy documents30 minutes, plus bust on publishCached per document set version
Customer record20 seconds, keyed by record versionPlaced after the cache boundary, not in the cached prefix

The burst of five requests in two minutes now behaves like this: the first request writes the local entry and the provider prefix; requests two through five hit both layers. The customer record is fetched fresh if more than 20 seconds have passed, and a profile edit bumps the version so the next request cannot hit a stale entry. The stable 1,200 token block is shared across every customer, so its prefix entry stays warm all day and gets read thousands of times per write.

The gain is not just latency. It is that you can now state, for each source, how old the data a user sees is allowed to be, and prove it from logs.

Logging which layer served the answer

You cannot tune a TTL matrix you cannot see. Add three fields to every response log line, at minimum:

  • served_by: which layer produced the content, one of local, prefix, provider, or miss.
  • age_ms: how old the served entry was when it was used.
  • source_version: the version or hash of the data source that the entry was built from.

Provider APIs already give you part of this. OpenRouter's response caching docs return X-OpenRouter-Cache-Status: HIT or MISS along with X-OpenRouter-Cache-Age and X-OpenRouter-Cache-TTL, so the prefix side can be read straight from the response headers rather than inferred. The OpenRouter announcement notes the same headers and a per-request TTL override, which is what makes a per-source matrix practical rather than theoretical.

A minimal log record looks like this:

{
  "request_id": "req_8f21",
  "source": "customer_record",
  "served_by": "local",
  "age_ms": 18400,
  "local_ttl_s": 20,
  "source_version": "v42",
  "prefix_status": "HIT",
  "prefix_age_ms": 18400
}

The important habit is to log the decision, not just the outcome. If a request is served from the local layer, log why the local entry was considered valid, and log the prefix status of the request that produced it. When a stale answer reaches a user, the first question is always which layer handed it over, and the log should answer that without a reconstruction.

Using the layer field to spot drift

Invalidation drift is the slow divergence between what each layer believes is current. It shows up as a pattern in the served_by and age_ms fields long before users complain, if you look.

Symptom in the logsLikely causeFix
Stale answers served by local with age near the local TTLLocal TTL too long for the source's change rateShorten local TTL, or bust on write
Stale answers served by local with low ageA write did not invalidate the local keyAdd explicit bust on write, key by version
Many local hits but prefix_status: MISSLocal TTL far longer than prefix TTLBring the two into line
High prefix hit rate but old age_msPrefix TTL longer than the source's toleranceLower the prefix TTL or shorten the cached prefix
provider on almost every callPrefix never stays identical, or TTL too short to surviveCheck prompt ordering, then raise TTL
age_ms clustered at the TTLNo jitter, expirations are synchronizedAdd jitter to the local TTL

The decision rule that follows is short. If stale answers come from the local layer, the local TTL or its bust logic is wrong, because that layer is the one that delivers outdated content. If stale answers come from the prefix layer, the local TTL is too long relative to the prefix TTL, so the local layer is holding content that the provider has already discarded and the user is being served a version the layer below has given up on.

Watch the age distribution, not just the average. A source whose age_ms p95 sits right at its TTL is a source where a meaningful share of users see the oldest permitted version. If that is not acceptable, the TTL is too long, whatever the average looks like.

When to skip one of the layers

The matrix is not a rule that every source needs two TTLs. Some sources should only have one.

Skip the local cache for sources where the tolerable staleness is zero and the fetch is cheap, like a live status feed or a per-request identifier. Caching something you must always re-read just adds a way to be wrong.

Skip the prefix cache for content that is volatile or customer-specific. If a source's TTL is under a minute, the write premium will not pay back, and the content actively harms hits for everything downstream if placed before the boundary. Keep it after the cached prefix instead.

Keep both for the sources that dominate token count and change slowly: system prompts, tool definitions, document sets, and long conversation history. Those are where the local layer saves a serialization and a fetch, and the prefix layer saves the prefill.

One more control worth having: an explicit bust path that is separate from TTL expiry. A publish, a profile edit, or a deploy should invalidate the local key immediately and change the source version. OpenRouter's caching docs describe the related idea at the routing layer, where a sticky provider keeps a cache warm and a failed request does not update it. Your bust path should be just as explicit: version the key, bump the version on write, and let the old entry age out naturally rather than trying to reach into the provider.

If you are running several models behind one interface, the matrix is also the place to record that different providers have different prefix policies and lifetimes. Routing the same workload through one endpoint with a single key, such as Quanta Inference, keeps the per-source TTLs and the served_by values comparable across models, which makes the drift table above much easier to read.

FAQ

FAQ

Why do I need two TTLs instead of one?

The two layers store different things and fail differently. The local cache stores the text you send, so a stale entry produces a wrong answer. The provider prefix cache stores computed state, so an expired entry only costs extra prefill. Setting them independently lets you keep the local TTL as short as correctness demands while letting the prefix TTL run longer for cost, where the data allows it.

How do I choose the local TTL for a source that changes unpredictably?

Set the local TTL from the tolerable staleness, not the change rate, then add explicit invalidation on write. If a user would file a ticket after seeing data that is 30 seconds old, the local TTL is 30 seconds or less regardless of how often writes happen. The change rate only tells you how much you would gain from a longer TTL.

What exactly should the served_by field contain?

At minimum, which layer produced the content: local, prefix, provider, or miss. Pair it with the entry age and the source version. That triple answers the first question in any staleness investigation: which layer handed this over, how old was it, and which version of the source was it built from.

Can I read the prefix cache status without guessing?

Yes, on providers that expose it. OpenRouter returns cache status, age, and remaining TTL as response headers, so you can log the prefix layer's behavior directly instead of inferring it from latency. Where a provider does not expose it, log the request prefix hash and count distinct values to estimate hit rate.

What is the single most common cause of invalidation drift?

Making the local TTL much longer than the prefix TTL. The local layer then serves content that the provider has already dropped, so every served request is a local hit that still pays full prefill, and the user sees the older version. Matching the two, or making the prefix TTL the longer one, removes most of the drift you will see in the logs.

Sources

Sources

  1. Prefix caching - LLM Inference Handbook - Modular
  2. Prompt caching - Claude Platform Docs
  3. Automatic Prefix Caching
  4. Prefix Caching - Furiosa Docs
  5. Prompt Caching in 2026: Cut LLM Costs, Keep Quality
  6. Prompt Caching breakdown: Cut token spend in 2026
  7. How to Prevent a Cache Stampede
  8. Cache Identical API Responses - OpenRouter
  9. Response Caching: Zero Cost for Identical Requests - OpenRouter
  10. Prompt Caching - Optimize AI Model Costs with Smart Caching
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles