CostCompass An Almanac Beta
Engineering Guide

A stuck cache is a diagnosis, not a mystery

Prompt caching breaks quietly — the request succeeds, no error is raised, and the bill just doesn't move. But the provider already told you what happened in the response. This guide reads those counters as a decision tree: did the cache write, did it read, and did it save — and if the read failed, exactly which part of your request broke the match.

By Joubert Berger Published July 9, 2026

You did everything the how-to said. You structured a stable prefix, added the cache marker, and shipped it. The requests still succeed, no error shows up in the logs, and the bill looks exactly the same as last week. Nothing is broken in any way your monitoring can see, and that is the problem. Prompt caching doesn’t fail loudly. When it fails, the response comes back normal and the only trace is a number in the usage object that you were not looking at.

This guide is about reading that number. It is the diagnostic companion to two others: Prompt Caching Explained covers the economics, whether caching will pay off on a given workload, and How to Enable Prompt Caching covers turning it on and confirming it once. This guide picks up where those two end: caching is on, you believe it should be helping, and it isn’t. It won’t re-derive the write premium or re-teach the enablement steps; where those matter, it links back. Its job is the part neither of them covers — diagnosing a cache that is enabled and misbehaving.

A stuck cache leaves a trail. Every provider hands you enough information in the response to localize the failure, and the whole of troubleshooting reduces to reading two counters in the right order and, when the second one is wrong, narrowing the cause to a single part of your request. The rest of this guide is that method worked out in detail.

The one question that becomes three

When caching isn’t working, the provider’s own token counters tell you which of three things failed, and you check them in a fixed order:

  1. Did the cache write? Was anything stored at all?
  2. Did the cache read? Was the stored prefix matched on a later request?
  3. Did it actually save? Did the reads outweigh what the writes cost?

Each question has its own counter and its own fixes. A write-side failure means nothing was stored: the write counter is zero. A read-side failure means something was stored but never matched; the write counter is non-zero and the read counter is zero. An economics-side failure means the cache stored and read exactly as designed, but the reuse was too thin to pay back the write premium. Both counters look healthy and the bill still didn’t drop.

The order matters because the questions are dependent. There is no point hunting for a broken prefix if nothing was ever stored, and there is no point weighing savings if the cache never reads. Walk the tree top to bottom and each answer either resolves the problem or hands you to the next question.

A top-to-bottom decision tree for diagnosing a prompt cache. The first node reads the write counter; a zero write branches to the write-side region (nothing stored: below minimum size, or a marker, format, or gateway mistake). A non-zero write leads to the second node, which reads the read counter on a repeat request; a zero read branches to the read-side region, which fans out into four request segments — model, system prompt, tool definitions, and message history. A non-zero read leads to the third node, which asks whether reads outweigh writes; if not, it branches to the economics-side region (reuse below break-even, TTL expiry, sparse traffic).
Figure 1. The cache-failure decision tree. Read the write counter, then the read counter, then compare reads against writes. Each answer places the failure in one of three regions — write-side, read-side, or economics-side — and a read-side failure narrows further to the one request segment that broke the match. This is the whole diagnostic method.

The sections that follow walk the tree branch by branch: first the counters themselves, then each of the three regions in order.

The counters, and what they are called on your provider

You cannot run the decision tree without reading the counters, so start by finding them. Conceptually every provider reports the same three quantities on each response: how many input tokens it wrote to the cache, how many it read from the cache, and how many it processed fresh (the uncached remainder). The names differ, and the numbers are dated because they and the surrounding APIs drift — reverify against the provider’s own docs before you rely on them. As of July 6, 2026:

ProviderCache-write counterCache-read counterUncached remainder
Anthropiccache_creation_input_tokenscache_read_input_tokensinput_tokens
OpenAI(no separate write counter; automatic caching)cached_tokens (under usage.prompt_tokens_details)prompt_tokens less cached
Amazon BedrockcacheWriteInputTokenscacheReadInputTokensinputTokens
Google Gemini(reported via cached-content count)cached-content tokens (under usageMetadata)remainder of prompt tokens

The field names come from each provider’s prompt-caching documentation: Anthropic, OpenAI, Amazon Bedrock, and Google Gemini.

Two structural differences matter for diagnosis. First, OpenAI caches automatically and charges no write premium, so it does not surface a distinct write counter the way the explicit providers do. Its diagnostic signal is just whether cached_tokens is non-zero. Second, the explicit providers (Anthropic, Bedrock) report the write and read separately, which is what makes the write-side versus read-side split observable in the first place: you can see the difference between “nothing was stored” and “something was stored but not matched.”

Here is what a write-then-read pair looks like on the wire. The counts below are illustrative, not from a live call, and the field paths drift — check them against each provider’s docs before you rely on them. On Anthropic (Claude API), the write and read counters move in opposite directions between the two calls:

// Call 1 — writes the prefix (read counter still 0)
"usage": {
  "input_tokens": 18,
  "cache_creation_input_tokens": 1534,
  "cache_read_input_tokens": 0,
  "output_tokens": 92
}
// Call 2 — same prefix, now served from cache
"usage": {
  "input_tokens": 18,
  "cache_creation_input_tokens": 0,
  "cache_read_input_tokens": 1534,
  "output_tokens": 88
}

Amazon Bedrock reports the same split under camelCase names:

// Call 2 — the read
"usage": {
  "inputTokens": 18,
  "cacheWriteInputTokens": 0,
  "cacheReadInputTokens": 1534,
  "outputTokens": 87
}

OpenAI has no write counter — caching is automatic — so the only signal is cached_tokens going non-zero on the repeat call:

// Call 2 — prefix hit
"usage": {
  "prompt_tokens": 1552,
  "prompt_tokens_details": { "cached_tokens": 1536 }
}

Google Gemini breaks the cached share out of the prompt count under usageMetadata:

// Call 2 — cached content counted separately
"usageMetadata": {
  "promptTokenCount": 1552,
  "cachedContentTokenCount": 1536,
  "candidatesTokenCount": 88,
  "totalTokenCount": 1640
}

Extended to three calls, that shape is the reference pattern: the first call writes, the second and third read, their write counters dropping to zero and their read counters carrying the bulk of the input. If your traces look like that, caching is engaged, and any remaining problem is about economics rather than a broken cache. If they don’t, the deviation tells you which region you are in.

Write-side: nothing was stored

Start here whenever the write counter is zero (or, on OpenAI, whenever cached_tokens is zero and stays zero even on the second identical call). A zero write means the provider never stored anything, so no amount of reuse will ever produce a read. Two causes account for nearly all of it.

The prefix is below the model’s minimum cacheable size. Every provider enforces a floor: below some number of tokens in the cacheable region, it stores nothing, returns a perfectly normal response, and raises no error. The only symptom is that both counters stay at zero forever. The floor is per-model, not just per-provider. Anthropic requires 1,024 tokens for Claude Opus 4.8 and Claude Sonnet 5 (higher for some models); OpenAI caches automatically at 1,024 tokens and up; Amazon Bedrock sets it per model per cache checkpoint (1,024 tokens for Claude 3.7 Sonnet, 4,096 for Claude Opus 4.5 and Sonnet 4.5); and Google Gemini ranges from 2,048 to 4,096 tokens depending on the model. Check the tokens ahead of your cache boundary against the floor for the exact model you are calling — and confirm you are calling the model you think you are, because a fallback to a smaller model can quietly raise the floor out from under you.

The marker never took effect. The prefix clears the minimum, but the cache instruction was malformed or lost, so the provider treated the request as uncacheable. The classic version on explicit providers is placing the cache control at the wrong level — attaching it to a plain-string system field at the message level, rather than to the content block the provider actually inspects. The request succeeds, the large system prompt is re-sent and billed in full on every call, and the counters never leave zero. The other common version is a gateway or SDK in the path that re-shapes your request and drops or relocates the marker in translation — a proxy, a framework adapter, or a provider-router layer that “supports caching” but only for a request shape yours doesn’t match. The tell is the same either way: your code enabled caching, and the counters say it didn’t.

Both fixes are structural or configuration; the prefix’s content is beside the point. Enlarge the prefix above the floor, or place the marker where the provider expects it and confirm nothing between you and the provider is rewriting the request. The exact placement per provider belongs to the enablement guide; this guide’s job is only to tell you, from the counters, that write-side is where to look.

A two-by-two grid mapping two counter readings to a failure region. Rows are the cache-write counter (zero or non-zero); columns are the cache-read counter on a repeat request (zero or non-zero). Write zero, read zero is write-side (nothing stored — below minimum size, or a marker, format, or gateway mistake). Write non-zero, read zero is read-side (stored but the prefix diverged; localize the segment). Write zero, read non-zero is normal (reading a prefix an earlier call wrote). Write non-zero, read non-zero with a flat bill is economics-side (verify reuse against break-even).
Figure 2. The two counters, read together, place the failure. A zero write is write-side; a non-zero write with a zero read is read-side; healthy counters with a flat bill are economics-side. This grid is the fast version of the decision tree's first two branches.

Once the write counter is non-zero, you know something was stored. If reads are still zero, the prefix is being stored and then missed. That is the next region, and the deepest one.

Read-side: the cache writes but never reads

This is the failure that generates the most confusion and the most search traffic, because it looks contradictory: the write counter proves the cache is storing your prefix, yet the read counter is stuck at zero, so it is storing the same thing over and over and never reusing it. On an explicit provider that means you pay the write premium on every single call and collect none of the read discount. It’s the worst possible outcome, and one the response would have told you about on the second request.

The mechanism behind every read-side failure is the same. The cache match is a byte-exact comparison of the leading prefix, evaluated in order from the very first token, that stops at the first token that differs. Everything after that first difference is treated as uncached, even when it is identical to what you sent before. So a single per-request value that has drifted toward the front of the request resets the match for the entire prefix behind it. “The prefix broke” is never vague once you know this. The break is at one specific point, and that point falls inside exactly one part of the request.

A request has an ordered structure, and the divergence lives in exactly one segment of it:

  • Model. The cache is per-model. If a router, an A/B split, or a fallback selected a different model than the request that wrote the entry, nothing matches — the cache for one model is invisible to another. This is the cause behind a lot of “high writes, zero reads on most turns” reports: an auto-reply or retry path quietly switches models.
  • System prompt. A per-request value interpolated into the system prompt is the single most common read-side cause. A timestamp, a request id, a session identifier, a personalized greeting, or a “current date” line near the top changes the leading bytes on every call and forces a fresh write each time.
  • Tool definitions. Tools added, removed, or reordered between turns change the prefix, as does a tool schema serialized with non-deterministic key order — two runs that are semantically identical produce different bytes. Watch for layers that inject or reorder tools for you; some gateways place tool definitions ahead of the system prompt, so any tool change invalidates everything.
  • Message history. Caching a conversation assumes history is append-only. If an earlier message was edited, truncated, reordered, or re-serialized differently on resend — including assistant turns and tool results echoed back in a new shape — the prefix diverges at the point of the edit and everything after it misses.

There are two subtler variants worth naming because they hide inside those four. One is inconsistent serialization: a templating layer that normalizes whitespace differently between runs, or JSON emitted with unstable key order, so requests that look identical tokenize differently. The other is isolation boundaries: caches are typically scoped to an API key, workspace, or account, so two calls made with different keys or from different workspaces cannot reuse each other’s entries even when the bytes match perfectly.

A single request drawn as an ordered horizontal band of labelled segments — model, system prompt, tool definitions, message history, then a volatile tail. A vertical 'first divergence' marker sits partway along; everything to its left is shaded matched and cacheable, and everything to its right, including the rest of the prefix, is shaded treated as uncached. Each segment carries a short cause and fix: model — a router or fallback switched models; system — a timestamp or id interpolated in; tools — reordered or non-deterministically serialized; history — edited instead of appended.
Figure 3. The read-side segment map. The byte-exact match runs left to right and ends at the first divergence, so everything behind it — even identical content — is billed fresh. Localizing that first divergence to one segment (model, system, tools, or history) points straight at the cause and the fix.

The discipline follows directly: find the first divergence. Everything downstream of it is a consequence, not a separate bug, so fix the earliest one and re-measure before chasing anything further back. The fastest way to find it is to let the provider do the comparison for you.

Let the provider point at the divergence

Eyeballing two large requests for a one-token difference is miserable and error-prone. On the Claude API, you don’t have to: the Cache Diagnostics feature compares two consecutive requests and tells you where they first diverged. It is a beta (header cache-diagnosis-2026-04-07, and its field names may change): you pass the previous response’s id as diagnostics.previous_message_id, and the response carries a cache_miss_reason naming the first segment that changed. It maps one-to-one onto the read-side segments above:

cache_miss_reason typeWhat divergedWhere to look
model_changedThe model differs from the prior requestA router, A/B test, or fallback picked a different model; hold the model constant
system_changedThe system prompt differsA timestamp, id, or per-request value was interpolated in; make the system prompt byte-stable and move dynamic data after the breakpoint
tools_changedThe tools array differsTools added, removed, reordered, or non-deterministically serialized; send a fixed list with sorted-key schemas
messages_changedAn earlier message was alteredHistory was edited, truncated, or re-serialized instead of appended; treat it as append-only
previous_message_not_foundNo stored fingerprint for that idNot evidence your request changed — the prior call likely lacked the beta header, used a different workspace, or was too long ago
unavailableNo comparison was producedAnother prompt-affecting parameter differs (for example tool_choice, thinking, or output settings), or the change is beyond the comparison horizon

Two cautions. The tool reports the earliest divergence only; later ones can be hidden behind it, so fix the reported segment and run it again. And it is Claude API only; it is not available on Amazon Bedrock or Google Cloud, and no equivalent cross-request diagnostic exists on the other providers. Where the tool isn’t available, you reproduce it by hand: serialize the exact bytes of two requests that should have matched and diff them front to back. The idea generalizes even if the instrument doesn’t. You are always looking for the first point where two supposedly-identical prefixes differ.

Cache Diagnostics also answers a question the raw read counter can’t. Its result combines with cache_read_input_tokens to separate “my request changed” from “my request matched but the entry was gone”:

Diagnostics resultCache read tokensInterpretation
No divergence found (null)highWorking as intended — stable prefix, cache hit.
No divergence found (null)low or zeroRequests matched, but the entry had already expired — a TTL problem, not a prefix bug; shorten the gap between calls or use a longer cache duration.
A *_changed reasonlow or zeroYour bug — the request changed; fix the named segment.
A *_changed reasonhighRare — a late change occurred but an earlier breakpoint still hit; low impact.

That second row is why the read counter alone isn’t enough. A zero read doesn’t always mean your prefix broke. The prefix may have been fine and the entry simply expired before you reused it — one case sends you to fix your request, the other to fix your timing. That is the last region: a cache that reads exactly as designed and still doesn’t save.

Economics-side: it reads, and the bill still didn’t drop

Suppose you’ve done all of the above. The write counter is healthy, the read counter is non-zero and growing, Cache Diagnostics reports no divergence. The mechanism is working by every check in this guide. And the monthly cost is flat, or higher than before you enabled caching. Nothing is broken. Caching engaged; it just didn’t pay, and you can pass that first check while failing the second.

The reason is that a cache read is cheap but a cache write, on an explicit provider, is billed above the normal input rate. That premium only comes back if enough reads follow before the entry expires. A cache that writes far more often than it reads loses money, cleanly and quietly, while every counter says “working.” The break-even sits a little above one read per write once the write premium is included; below it, caching costs more than doing nothing. The full derivation — the write premium, the read discount, the TTL deadline, and where a given workload lands — is the Prompt Caching Explained guide’s job, and this guide doesn’t repeat it. For diagnosis, what you need is to recognize an economics failure so you don’t misfile it as a bug:

  • Reuse slower than the TTL. Entries expire between calls, so each request writes a fresh one and few are ever read. This is the case that passes a tight back-to-back test and then underperforms in production, where traffic is spread out. It is the TTL row of the diagnostics matrix above.
  • Sparse or bursty traffic. A prefix hit a couple of times an hour never accumulates enough reads inside a short entry lifetime to clear break-even.
  • Parallel calls with no warm-up. Several requests that share a prefix but all fire before any of them has written the entry each pay to write it, and the hit rate collapses — a documented way to end up with costs higher than running without caching at all.
  • Output-dominated cost. Caching only touches input processing. If your spend is mostly long generations, a working input cache barely moves the total no matter how high the read counter climbs.

The fixes here are about timing and traffic, not markers: cluster requests that share a prefix so they land inside the TTL, warm the entry once before fanning out parallel work, request a longer cache duration where the provider supports it, or accept that this particular workload doesn’t earn back the write and turn caching off for it. That last option is legitimate. An economics-side “failure” is sometimes just caching correctly reporting that your workload isn’t a fit.

Keep it from silently regressing

Every failure in this guide is silent by construction: the request succeeds, no error is raised, and only a counter moves. That has a consequence beyond the initial fix. A cache you diagnosed and repaired today can break again next month from a change that looks entirely unrelated — a new per-request field added to the system prompt, a tool reordered in a refactor, a debug timestamp left in after an incident, a gateway upgrade that reshapes requests. None of those raise an error. The only thing that catches them is the cache-read counter you now know how to read, watched over time rather than once.

So the last step of troubleshooting is to stop troubleshooting reactively. Wire the read counter into whatever you already monitor, and track two signals: the cached-token share of your input, which should hold near the stable-prefix fraction you expect, and the cost-per-request at steady usage, which should stay down. When either drifts, you are back at the top of the decision tree, but now you catch it as a metric moving, not as a surprise on an invoice a month later.

On a single provider you can read those signals straight from the usage object. If you call several providers, they scatter across different billing surfaces with different field names, which is where pulling them into one place earns its keep; tracking AI costs across providers covers that, and CostCompass pulls usage from your connected providers into one running total so cached-token share and cost-per-request are visible when you look, instead of being reconstructed from four dashboards after the fact.

Next steps

A stuck prompt cache is a diagnosis, not a mystery. Read the write counter, then the read counter, then compare the reads against the writes, and every failure lands in one of three places: nothing was stored (write-side), something was stored but your prefix diverged in one specific segment (read-side), or it stored and read exactly as designed but the reuse was too thin to pay (economics-side). The provider already reports everything you need to place the failure; the skill is knowing which counter answers which question, and in what order.

From here:

  • Understand the economics behind the third region. Prompt Caching Explained derives the write premium, read discount, and break-even that decide whether a working cache actually saves.
  • Read it off the Claude Console. How to read Claude’s cache usage dashboard runs this same diagnosis visually — the read ratio, write amortization, and the Cache diagnostics panel that names the divergent segment — without adding any logging.
  • Revisit how you turned it on. How to Enable Prompt Caching has the exact per-provider markers and the verify-once procedure, which is where most write-side and read-side fixes are applied.
  • Watch it over time. Tracking AI costs across providers covers keeping the cache-read counter and cost-per-request in view so a regression shows up as a moving metric.

The counters were reporting all of this the whole time. Troubleshooting is learning to read them in order.

Frequently asked questions

How do I check whether prompt caching is actually working?
Read the cache counters in the response's usage object — the provider reports them on every call. Anthropic returns cache_creation_input_tokens (written) and cache_read_input_tokens (served from cache); OpenAI reports cached_tokens under usage.prompt_tokens_details; Amazon Bedrock returns cacheWriteInputTokens and cacheReadInputTokens; Google Gemini reports a cached-content token count under usageMetadata. Send the same-prefix request twice: the first call should show a write, the second should show a read. A read count that grows across repeated calls is proof the mechanism engaged. Everything else in troubleshooting is deciding which counter is wrong.
My cache write count is high but cache reads are always zero — what does that mean?
The provider stored your prefix and then never matched it. That is a read-side failure: something in the leading part of your request changed between calls, so the byte-exact prefix match ended before it reached your cached content. The change lives in exactly one request segment — the model, the system prompt, the tool definitions, or the earlier message history. Diff two "identical" requests and look for a per-request value near the front (a timestamp, a request id, a reordered tool list, an edited earlier message). On the Claude API, the Cache Diagnostics beta names the divergent segment for you.
Both my cache-write and cache-read counts are zero — why is nothing being cached?
Nothing was stored, which is a write-side failure with two usual causes. Either the cacheable prefix is below the model's minimum cacheable size — under that floor the provider silently stores nothing and returns a normal response — or the cache marker never took effect because of a formatting or gateway mistake (for example, cache_control attached at the message level on a plain-string system field instead of on the content block, or an SDK or proxy that dropped the marker in translation). Confirm the prefix clears the model's minimum, then confirm the marker is on the content block your provider expects.
Why does changing one small field break my whole prompt cache?
Because the match is a byte-exact comparison of the leading prefix, evaluated in order from the first token, and it stops at the first token that differs. Everything after that first difference is treated as uncached, even if it is identical to a previous request. So a single volatile value near the front — a timestamp in the system prompt, a request id, a reordered JSON key, an extra whitespace from a template change — invalidates the entire cacheable region behind it. The fix is structural: keep the leading prefix byte-for-byte constant and move anything per-request to the end of the request.
How do I find where my prompt prefix diverged between requests?
Compare two consecutive requests that should have matched. On the Claude API, the Cache Diagnostics beta (header cache-diagnosis-2026-04-07) does this for you: pass the previous response id and it returns a cache_miss_reason naming the first segment that changed — model_changed, system_changed, tools_changed, or messages_changed. On providers without a diagnostics tool, reproduce the same comparison manually: serialize the exact bytes of both requests and diff them, working front to back, until you find the first difference. The first difference is the whole problem; fixes further back are hidden behind it.
Prompt caching is working but my costs didn't go down — is it broken?
Not necessarily. A cache can read correctly and still cost you money — checking that it engaged and measuring that it paid are separate things. On a provider that charges a write premium, the premium you pay to store a prefix only pays back if enough cheap reads follow before the entry expires. If your reuse is sparse — a slow trickle of requests, entries expiring between calls, or parallel calls that all write before any reads — the reads can be real and the bill can still rise. That is an economics problem, not a broken cache. The prompt-caching-explained guide covers the break-even math that decides it.

About the author

Joubert Berger builds CostCompass, a spend-intelligence dashboard that pulls usage from AI and compute providers into one month-to-date total, a forecast, and a per-provider breakdown. This guide reflects how CostCompass reads each provider's own usage API — see the security model for how your keys are handled.

Watch the counter that catches a broken cache

A cache regression shows up first as a cache-read count that quietly falls back to zero. CostCompass pulls usage from your connected providers into one running total, so cached-token share and cost-per-request are there when you look instead of buried in an end-of-month invoice.