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.
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:
- Did the cache write? Was anything stored at all?
- Did the cache read? Was the stored prefix matched on a later request?
- 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.

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:
| Provider | Cache-write counter | Cache-read counter | Uncached remainder |
|---|---|---|---|
| Anthropic | cache_creation_input_tokens | cache_read_input_tokens | input_tokens |
| OpenAI | (no separate write counter; automatic caching) | cached_tokens (under usage.prompt_tokens_details) | prompt_tokens less cached |
| Amazon Bedrock | cacheWriteInputTokens | cacheReadInputTokens | inputTokens |
| 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.

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.

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 type | What diverged | Where to look |
|---|---|---|
model_changed | The model differs from the prior request | A router, A/B test, or fallback picked a different model; hold the model constant |
system_changed | The system prompt differs | A timestamp, id, or per-request value was interpolated in; make the system prompt byte-stable and move dynamic data after the breakpoint |
tools_changed | The tools array differs | Tools added, removed, reordered, or non-deterministically serialized; send a fixed list with sorted-key schemas |
messages_changed | An earlier message was altered | History was edited, truncated, or re-serialized instead of appended; treat it as append-only |
previous_message_not_found | No stored fingerprint for that id | Not evidence your request changed — the prior call likely lacked the beta header, used a different workspace, or was too long ago |
unavailable | No comparison was produced | Another 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 result | Cache read tokens | Interpretation |
|---|---|---|
No divergence found (null) | high | Working as intended — stable prefix, cache hit. |
No divergence found (null) | low or zero | Requests 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 reason | low or zero | Your bug — the request changed; fix the named segment. |
A *_changed reason | high | Rare — 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.