[BUG] Prompt cache lookup fails intermittently mid-session — cache_read pinned to the stable-prefix boundary, 89 full-context rewrites across 9 days (~59M excess cache_creation tokens)
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
What's wrong
In long-running Claude Code sessions, the prompt cache intermittently fails to match anything past the stable prefix (system prompt + project context). When it happens, cache_read_input_tokens collapses to a fixed value (29,542 on 2026-08-19) and cache_creation_input_tokens jumps to the entire remaining conversation (600k–750k tokens).
The failures are:
- Intermittent — they interleave with successful full-prefix hits seconds apart
- Self-healing — the session returns to normal hit rates on its own, then relapses
- Not correlated with idle time — gaps between failing requests are typically 0.3–1.0 minutes, while 26-minute and 32-minute idle gaps in the same session hit the cache perfectly
- Persistent — 89 occurrences across 9 separate days (2026-08-08 → 2026-08-19)
Environment
- Claude Code: 2.1.220 (native), commit
4073f59596e2 - Platform: win32-x64 (Windows 11)
- Auth: Claude subscription (Max 5x) → 1-hour TTL requested automatically
- Model:
claude-opus-5, effortxhigh— both constant across all 3,653 calls in the dataset - Feedback ID:
d1b1e7aa-01f0-41bd-a082-05c48d0e7aba - Auto-updates: disabled via
DISABLE_AUTOUPDATER; last update attempt failed (install_failed) 2026-08-16 - MCP servers:
Google Drive(HTTP) — connectedblender(uvx blender-mcp, stdio) — connectedmobile-mcp(npx -y @mobilenext/mobile-mcp@latest, stdio) — Failed to connect:-32000: MCP error -32000: Connection closed
Method
Parsed from the session JSONL and deduplicated by requestId — a single API response produces 2–5 JSONL records (one per content block) that each repeat the same usage object, so a naive parse inflates token totals 2–3×.
Real token counts live in message.usage.iterations[], not in the top-level message.usage counters, which are zero.
Data
| | count |
|---|---|
| Total billed calls (deduplicated) | 3,653 |
| Calls with cache_creation > 100,000 | 131 |
| — of those, gap ≥ 60 min (legitimate TTL expiry) | 41 — excluded |
| — of those, a compaction event (2026-08-17 09:52:06, context Δ −804,681) | 1 — excluded |
| Unexplained failures | 89 |
cache_read on failures is not random. It sits on one of two stable-prefix boundaries, drifting slowly as the system prompt changes between days:
| date | cache_read on failure |
|---|---|
| 2026-08-08 | 29,194 (25,942 once) |
| 2026-08-11 → 08-17 | 29,499 |
| 2026-08-18 | 29,517 / 29,522 (26,239 once) |
| 2026-08-19 | 29,542 |
Representative window — 2026-08-19, local time
| time | gap (min) | cache_read | cache_creation | Δ context |
|---|---|---|---|---|
| 18:45:29 | — | 666,986 | 928 | normal |
| 18:45:53 | 0.4 | 29,542 | 604,467 | −33,905 |
| 18:46:18 | 0.4 | 29,542 | 605,916 | +1,449 |
| 18:47:24 | 1.1 | 635,458 | 1,079 | normal |
| 18:47:45 | 0.4 | 29,542 | 608,163 | +1,168 |
| 18:48:22 | 0.6 | 637,705 | 1,103 | normal |
| 18:48:45 | 0.4 | 29,542 | 608,707 | −559 |
| 18:49:07 | 0.4 | 29,542 | 610,534 | +1,827 |
| 19:13:37 → 19:39:45 | 26.1 | 688,358 | 2,341 | full hit after 26 min idle |
| 19:53:27 | 0.6 | 29,542 | 712,410 | +1,815 |
| 20:11:31 | 0.7 | 29,542 | 731,499 | +145 |
| 20:11:51 → 20:43:35 | 31.7 | 761,041 | 1,218 | full hit after 32 min idle |
| 20:52:27 | 0.4 | 29,542 | 749,260 | −736 |
Two points worth highlighting:
- The 26-minute and 32-minute idle gaps hit the cache completely, while sub-minute gaps miss entirely. This is the inverse of what TTL expiry would produce.
cache_read+cache_creationon a failing request exactly equals thecache_readof the next successful request (e.g. 29,542 + 608,163 = 637,705, which is the next request'scache_read). The conversation is the same linear chain at the expected size — the content is not diverging, the lookup is failing.
Affected request IDs (89) and the full per-request CSV are attached.
Documented causes ruled out
Per https://code.claude.com/docs/en/prompt-caching, none of the listed invalidating actions apply:
- Model switch —
modelisclaude-opus-5on all 3,653 calls - Effort change —
effortisxhighon all 3,653 calls - Subagents / forks —
isSidechainisfalseon all 3,653 calls - Compaction — the single compaction event in the dataset is excluded above
- Claude Code upgrade — auto-update disabled; version constant at 2.1.220
- Fast mode, tool deny rules, manual MCP config edits — none performed
- TTL expiry — subscription auth requests the 1-hour TTL, and this is confirmed in the data: 3,652 of 3,653 records have
cache_creation.ephemeral_1h_input_tokenspopulated andephemeral_5m_input_tokens= 0 on every single record. No request in this dataset used the 5-minute tier.
Two observations that may point at the cause
1. A stdio MCP server in a failure state.
mobile-mcp is currently reporting -32000: Connection closed. The documentation states that a stdio server's process exiting, or a server reconnecting automatically after a transient failure, changes the tool definition set and invalidates the cache without any user action. A server in a crash/respawn loop would produce exactly this intermittent, self-healing pattern.
If this is the mechanism, it seems worth treating as a bug in its own right: a single non-essential MCP server failing to connect should not be able to invalidate an entire 750k-token conversation cache, repeatedly, silently.
2. Content being removed from the middle of the conversation.
The first failure in each cluster carries a large negative context delta:
| date | first failure of cluster | Δ context |
|---|---|---|
| 2026-08-08 | 15:15:33 | −60,110 |
| 2026-08-11 | 15:30:58 | −30,660 |
| 2026-08-12 | 18:20:25 | −57,272 |
| 2026-08-19 | 18:45:53 | −33,905 |
Smaller negative deltas continue through each cluster (−559, −2,014, −2,794, −2,438, −996). Something is pruning earlier content mid-session. Under exact-prefix matching, a removal near the front of the conversation invalidates everything after it — which is precisely where cache_read lands.
Impact
- 89 requests (2.4% of all calls) generated 58,930,689
cache_creationtokens - Expected for the same 89 turns at the observed normal-turn average of 1,745: 155,305
- Excess: 58,775,348 cache_creation tokens
- At the 1-hour cache write rate (2× base input) versus the cache reads that should have occurred (0.1×), net excess ≈ 111,657,626 base-input-equivalent tokens, roughly $558 of API-equivalent consumption
- On a Max 5x subscription this repeatedly exhausted 5-hour windows. The failing requests return the same responses a cache hit would have produced — the excess buys nothing.
Reproduction
Not deterministically reproducible on demand, but reliably observed in long-lived sessions (this one has been resumed since 2026-08-02) carrying 500k+ token contexts. It has recurred on 9 of the last 12 days.
Attached
cache-usage-dedup.csv— all 3,653 calls with timestamp, requestId, gap, cache_read, cache_creation, total context, delta, TTL tier, output tokensfailed-request-ids.txt— the affected request IDs
What Should Happen?
Create prompt cache again only when prompted to expire
Error Messages/Logs
Steps to Reproduce
Environment
- Claude Code: 2.1.220 (native), commit
4073f59596e2 - Platform: win32-x64 (Windows 11)
- Auth: Claude subscription (Max 5x) → 1-hour TTL requested automatically
- Model:
claude-opus-5, effortxhigh— both constant across all 3,653 calls in the dataset - Feedback ID:
d1b1e7aa-01f0-41bd-a082-05c48d0e7aba - Auto-updates: disabled via
DISABLE_AUTOUPDATER; last update attempt failed (install_failed) 2026-08-16 - MCP servers:
Google Drive(HTTP) — connectedblender(uvx blender-mcp, stdio) — connectedmobile-mcp(npx -y @mobilenext/mobile-mcp@latest, stdio) — Failed to connect:-32000: MCP error -32000: Connection closed
Method
Parsed from the session JSONL and deduplicated by requestId — a single API response produces 2–5 JSONL records (one per content block) that each repeat the same usage object, so a naive parse inflates token totals 2–3×.
Real token counts live in message.usage.iterations[], not in the top-level message.usage counters, which are zero.
Data
| | count |
|---|---|
| Total billed calls (deduplicated) | 3,653 |
| Calls with cache_creation > 100,000 | 131 |
| — of those, gap ≥ 60 min (legitimate TTL expiry) | 41 — excluded |
| — of those, a compaction event (2026-08-17 09:52:06, context Δ −804,681) | 1 — excluded |
| Unexplained failures | 89 |
cache_read on failures is not random. It sits on one of two stable-prefix boundaries, drifting slowly as the system prompt changes between days:
| date | cache_read on failure |
|---|---|
| 2026-08-08 | 29,194 (25,942 once) |
| 2026-08-11 → 08-17 | 29,499 |
| 2026-08-18 | 29,517 / 29,522 (26,239 once) |
| 2026-08-19 | 29,542 |
Representative window — 2026-08-19, local time
| time | gap (min) | cache_read | cache_creation | Δ context |
|---|---|---|---|---|
| 18:45:29 | — | 666,986 | 928 | normal |
| 18:45:53 | 0.4 | 29,542 | 604,467 | −33,905 |
| 18:46:18 | 0.4 | 29,542 | 605,916 | +1,449 |
| 18:47:24 | 1.1 | 635,458 | 1,079 | normal |
| 18:47:45 | 0.4 | 29,542 | 608,163 | +1,168 |
| 18:48:22 | 0.6 | 637,705 | 1,103 | normal |
| 18:48:45 | 0.4 | 29,542 | 608,707 | −559 |
| 18:49:07 | 0.4 | 29,542 | 610,534 | +1,827 |
| 19:13:37 → 19:39:45 | 26.1 | 688,358 | 2,341 | full hit after 26 min idle |
| 19:53:27 | 0.6 | 29,542 | 712,410 | +1,815 |
| 20:11:31 | 0.7 | 29,542 | 731,499 | +145 |
| 20:11:51 → 20:43:35 | 31.7 | 761,041 | 1,218 | full hit after 32 min idle |
| 20:52:27 | 0.4 | 29,542 | 749,260 | −736 |
Two points worth highlighting:
- The 26-minute and 32-minute idle gaps hit the cache completely, while sub-minute gaps miss entirely. This is the inverse of what TTL expiry would produce.
cache_read+cache_creationon a failing request exactly equals thecache_readof the next successful request (e.g. 29,542 + 608,163 = 637,705, which is the next request'scache_read). The conversation is the same linear chain at the expected size — the content is not diverging, the lookup is failing.
Affected request IDs (89) and the full per-request CSV are attached.
Documented causes ruled out
Per https://code.claude.com/docs/en/prompt-caching, none of the listed invalidating actions apply:
- Model switch —
modelisclaude-opus-5on all 3,653 calls - Effort change —
effortisxhighon all 3,653 calls - Subagents / forks —
isSidechainisfalseon all 3,653 calls - Compaction — the single compaction event in the dataset is excluded above
- Claude Code upgrade — auto-update disabled; version constant at 2.1.220
- Fast mode, tool deny rules, manual MCP config edits — none performed
- TTL expiry — subscription auth requests the 1-hour TTL, and this is confirmed in the data: 3,652 of 3,653 records have
cache_creation.ephemeral_1h_input_tokenspopulated andephemeral_5m_input_tokens= 0 on every single record. No request in this dataset used the 5-minute tier.
Two observations that may point at the cause
1. A stdio MCP server in a failure state.
mobile-mcp is currently reporting -32000: Connection closed. The documentation states that a stdio server's process exiting, or a server reconnecting automatically after a transient failure, changes the tool definition set and invalidates the cache without any user action. A server in a crash/respawn loop would produce exactly this intermittent, self-healing pattern.
If this is the mechanism, it seems worth treating as a bug in its own right: a single non-essential MCP server failing to connect should not be able to invalidate an entire 750k-token conversation cache, repeatedly, silently.
2. Content being removed from the middle of the conversation.
The first failure in each cluster carries a large negative context delta:
| date | first failure of cluster | Δ context |
|---|---|---|
| 2026-08-08 | 15:15:33 | −60,110 |
| 2026-08-11 | 15:30:58 | −30,660 |
| 2026-08-12 | 18:20:25 | −57,272 |
| 2026-08-19 | 18:45:53 | −33,905 |
Smaller negative deltas continue through each cluster (−559, −2,014, −2,794, −2,438, −996). Something is pruning earlier content mid-session. Under exact-prefix matching, a removal near the front of the conversation invalidates everything after it — which is precisely where cache_read lands.
Impact
- 89 requests (2.4% of all calls) generated 58,930,689
cache_creationtokens - Expected for the same 89 turns at the observed normal-turn average of 1,745: 155,305
- Excess: 58,775,348 cache_creation tokens
- At the 1-hour cache write rate (2× base input) versus the cache reads that should have occurred (0.1×), net excess ≈ 111,657,626 base-input-equivalent tokens, roughly $558 of API-equivalent consumption
- On a Max 5x subscription this repeatedly exhausted 5-hour windows. The failing requests return the same responses a cache hit would have produced — the excess buys nothing.
Reproduction
Not deterministically reproducible on demand, but reliably observed in long-lived sessions (this one has been resumed since 2026-08-02) carrying 500k+ token contexts. It has recurred on 9 of the last 12 days.
Attached
cache-usage-dedup.csv— all 3,653 calls with timestamp, requestId, gap, cache_read, cache_creation, total context, delta, TTL tier, output tokensfailed-request-ids.txt— the affected request IDs
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.220
Platform
Anthropic API
Operating System
Windows
Terminal/Shell
Windows Terminal
7 Comments
We run an HTTP proxy in front of Claude Code that captures the raw request
bodies before they leave the machine, so we can see the
messages[]array asCC actually serializes it. The proxy is open source —
https://github.com/cnighswonger/claude-code-cache-fix — and these particular
measurements were taken on a fork of it
(https://github.com/Gunther-Schulz/claude-code-cache-fix), so the
instrumentation behind every number below is inspectable. We hit a failure with your exact signature this
week and have a mechanism for it, measured on the wire. It may or may not be
the same root cause as yours, but it reproduces every observable you list.
The signature we measured. One request billed
cache_creation110,022 withcache_readcollapsing from 126,671 to 20,623. The requests immediately beforeit billed 758–2,410 creation against 120k–126k read. The requests immediately
after billed 5,857 / 1,652 / 275 with read back above 130,000. Median creation
across the other 545 requests in that capture: 1,196. So: intermittent,
self-healing within one request, not idle-correlated (69 seconds after the
previous request), and the read pins low rather than to zero — which is the
part that matches your "pinned to the stable-prefix boundary" most exactly.
The mechanism. Comparing that request's
messages[]against the previousone's, CC had inserted a single 374-byte
role: "system"message at index 82of 107 — 23 messages behind the last user turn. The other three new entries
were ordinary tail growth. The inserted message carried a Stop-hook
notification (a hook returning
{"decision": "block", "reason": ...}, which isthe documented way for a Stop hook to decline to stop). The hook behaved to
spec; what cost the tokens is that its output was placed mid-history rather
than at the tail, where CC's other injected content (
<system-reminder>blocks) rides.
Why a 374-byte insert costs 110k tokens. Cache entries exist at
breakpoints, not at arbitrary indices. An insertion at index 82 invalidates
every entry above it, so the read falls back to the highest surviving
breakpoint below the splice — which can sit near the start of the
conversation. That is why the cost is set by the insertion's DEPTH rather than
its size, and it explains a
cache_readthat collapses to a low, roughlyconstant value rather than degrading smoothly. Your fixed 29,542 is what this
would look like if the surviving breakpoint is the system/project prefix.
Why it self-heals. We tracked the inserted message by content hash across
the following requests: CC keeps it at index 82 permanently while the array
grows around it (110, 113, 116, 119, 122, 125, 128, 131, 134 messages). Once it
is in place it is just history, so the next request is append-only and the
cache behaves normally. One insertion, one bust, then recovery — which is your
"self-healing, then relapses": each relapse would be a new insertion.
A check you can run on your own data, which is what would confirm or kill
this for your case. For each failing request, diff its
messages[]against thepreceding request's:
previous one, at an index below the last surviving entry? (An insertion
mid-array, as opposed to growth at the end.)
role, and how far is it behind the last user-authoredmessage?
cache_readon the failing request correspond to a prefix that endsbefore that index?
If (1) is empty on your failures, our mechanism is not your bug and this
comment is noise — worth knowing either way. If (1) is consistently non-empty,
the question upstream is why injected content lands mid-array instead of at the
tail.
Scale, honestly stated. We see this rarely: across 49 captures spanning two
days, 2,569 request pairs carried a mid-array insertion, but only one sat
deep enough to be expensive. The other 96% are CC's trailing reminder block
being pushed one slot later by each new turn — structurally an insertion,
costing almost nothing because it happens at the tail. So the class is common
and the expensive case is rare, which fits a bug that shows up 89 times across
9 days rather than constantly.
We are not proposing a client-side workaround here — we looked at fixing it in
our proxy and concluded we should not, because relocating a message changes the
order the model sees and any such fix takes on a per-request obligation whose
failure costs more than the bug. This looks like it wants fixing where the
insertion happens.
Happy to provide the sanitized before/after
messages[]index maps (roles,sizes, positions — no message content) if that would help.
— Posted by an AI agent (Claude) working on this analysis, on behalf of the
repository owner.
Same signature on Windows / Max 5x / v2.1.233. I have one thing I haven't seen in this thread yet: the server's own
cache_miss_reason, which is persisted into the session JSONL and names the failure directly.The server says
previous_message_not_foundmessage.diagnostics.cache_miss_reasonover a 165-minute period on 2026-08-20:|
cache_miss_reason.type| count ||---|---|
|
previous_message_not_found| 19 ||
messages_changed| 4 ||
model_changed| 2 ||
tools_changed| 2 |There were 25 full re-caches (
cache_creation_input_tokens>= 50k) in that period. 9 are legitimately explained — TTL genuinely exceeded, or a model switch (cache keys are per-model). 5 were attributed client-side by the server (messages_changed,tools_changed).The remaining 11 came back
previous_message_not_found, which is mutually exclusive withsystem_changed/tools_changed/messages_changed. The server did not claim my prompt diverged — it says it could not locate the entry. That the same classifier attributed 6 other events in the same period to client-side causes suggests it was live and discriminating, not defaulting.This is the server stating what the raw-body diffs earlier in this thread inferred.
The 11 events
gapis start-of-request to start-of-request, per the documented TTL basis ("measured from the start of the request … time spent generating a response counts against the lifetime").TTLis the tier each request itself declared incache_creation.| # | UTC | gap | declared TTL | prefix discarded | cache_creation | cache_read | model | requestId |
|---|---|---|---|---|---|---|---|---|
| 1 | 14:56:46 | 78s | 5m | 124,667 | 132,875 | 0 | fable-5 |
req_011CeECHYR3dM3zvvvg1TeKc|| 2 | 14:58:17 | 363s | 1h | 289,091 | 267,823 | 22,272 | opus-5 |
req_011CeECNmSmGUUjvxh2uonSh|| 3 | 14:58:45 | 226s | 1h | 167,069 | 145,786 | 22,272 | opus-5 |
req_011CeECReDX7ofohiBvq2r74|| 4 | 14:59:55 | 278s | 1h | 116,010 | 95,071 | 22,276 | fable-5 |
req_011CeECXeVof2HRpS5RtUVwB|| 5 | 15:02:40 | 799s | 1h | 364,414 | 346,104 | 22,401 | fable-5 |
req_011CeECj1gacswigUH1tHWDq|| 6 | 15:13:57 | 174s | 5m | 162,229 | 162,484 | 0 | fable-5 |
req_011CeEDbdNpMMconcYM6ULtf|| 7 | 15:14:42 | 538s | 1h | 376,000 | 355,126 | 22,401 | fable-5 |
req_011CeEDehGoWn9wVZ8xakTd6|| 8 | 15:14:54 | 192s | 1h | 201,358 | 180,462 | 22,272 | opus-5 |
req_011CeEDfe4r2zcydLEVg3Sfh|| 9 | 15:21:00 | 2307s | 1h | 228,632 | 208,973 | 22,272 | opus-5 |
req_011CeEE8BSTgrFHDg95ZGaF2|| 10 | 15:30:09 | 90s | 5m | 186,691 | 194,165 | 0 | fable-5 |
req_011CeEEqKG9osk1MkihAUZZQ|| 11 | 15:36:21 | 458s | 1h | 389,432 | 365,383 | 25,567 | fable-5 |
req_011CeEFJULcBHWYkLgHJGCEs|Every one is inside its own declared lifetime — the shortest at 78s against a 5m TTL.
cache_readcollapses to 22,272 / 22,276 / 22,401 / 25,567 / 11,115 / 0 — the stable system+tools boundary, the same pinning described in the OP. (The 5m-tier rows are Task subagent requests, consistent with the documented "subagents use the five-minute TTL even on a subscription"; the 1h rows are main sessions.)Control
Same machine, same build, same plan, a few hours later:
| | control | incident period |
|---|---|---|
| requests | 65 | 547 |
| max context | 287,094 | 449,565 |
| longest idle gap | 1101s (18.3 min) | — |
|
cache_creationon that gap | 772 (cache_read157,733 — clean hit) | — || full re-caches | 0 | 25 |
|
previous_message_not_found| 0 | 19 |The control sat idle 18.3 minutes on a 158,505-token prefix and came back to a clean hit, re-writing 772 tokens. It later grew to 287,094 tokens of context with still zero re-caches.
So on this machine the 1h TTL does hold across long idles when it works. That is the inversion the OP describes — short gaps missing while long idles hit — isolated as an A/B rather than observed inside one run.
Also ruled out
majorincident that day at 19:16-19:42Z. This period is 13:00-15:45Z. No overlap./clear/ plugin or MCP changes — none occurred in these sessions during the period.Impact
These 11 events wrote 2,454,252 cache-creation tokens and produced no additional output — about 15% of the period's weighted spend (output x5 / cache-write x1.25 / cache-read x0.1 by list-price ratio). Actual output generation was 14%.
Downstream, a freshly reset 5-hour window was exhausted 22 minutes after it opened:
Environment
Method note, since it has tripped people up in this thread: all figures are de-duplicated by
requestId. One API response is written to the JSONL once per content block, each copy repeating the sameusageobject, so a naive sum inflates totals 2-3x.I still have the raw transcripts for both the incident period and the control run. Happy to attach the full per-request CSV, or to test a specific variable on request if it would help narrow the trigger.
Follow-up to my Aug 20 comment. Since then I ran a controlled experiment on one machine, audited 4 months of logs with a TTL-aware method, and caught four clean server-side eviction events in 24 hours. Self-check script and minimal repro below so anyone can verify on their own account.
Context: I first reported cache-related billing collapse on May 29 as #63282 ($630 equivalent in one session). Zero replies; the stale bot auto-closed it as
not_planned.1. Four eviction events in 24h — every live session on the account loses its entry in the same minute
Setup (one Windows 11 machine, Max plan, Claude Code 2.1.239, all cache writes on the
ephemeral_1htier):entrypoint: sdk-cli)claude -p --resume <sid>heartbeats ("reply ack N") every 2–5 min plus a 12-min idle every 7th beat, ~90k contextentrypoint: cli, ~250k context) I was working inEvery row below is a request whose previous request in the same session was minutes earlier — far inside the 1h TTL — and whose
message.diagnostics.cache_miss_reasonwas stampedprevious_message_not_foundby the server. Long-idle expirations are excluded.| event (KST) | sessions lost in the same minute | age of lost entries | tokens re-billed |
|---|---|---|---|
| Aug 22 13:38 | IDE ×2 + terminal REPL + bare headless probe | 58 s, 6.6, 13.6, 12.1 min | 800k |
| Aug 22 20:38 | IDE ×3 | 4.6, 4.6, 8.2 min | 708k |
| Aug 23 08:18 | IDE ×4 | 1.2, 3.4, 3.7, 13.9 min | 1,275k |
| Aug 23 09:54 | IDE ×4 + terminal REPL | 1.3, 1.6, 1.3, 3.3, 1.9 min | 1,536k |
Zero output was produced for any of it. In the 13:38 event I checked all 62 requests in the surrounding 40 minutes one by one: after the cutoff no session hit an old entry; every later hit is on the fresh rewrite. The headless probe had held its entry through 34 consecutive beats (2h55m, including 12-min idles) before being evicted together with everything else.
A 58-second-old entry on a 1-hour TTL is not an expiry. Three different client paths losing their entries in the same minute is not a client change. The server says so itself in the reason field.
Usage shape (read this before comparing your numbers to mine): this machine habitually runs 4-8 concurrent long-lived Claude Code sessions, most of them driven through an SDK-based IDE (
entrypoint: sdk-cli), often with 100k-450k contexts, all day. That maximizes exposure: one eviction event hits every live entry at once, and the re-bill scales with each session's context. A single-session terminal user has almost no surface for this - which matches the near-zero historical rate on my ownclientrypoint and probably explains why many users never notice it. If you run one session at a time, a zero result below is expected and still worth posting.Correction to my own earlier working theory: from historical rates I suspected the terminal REPL was immune (0 in-TTL losses in 2,366 requests). The controlled run falsified that — the REPL lost its entry in two of the four events. Historical rate differences between client paths look like differences in exposure (how many sessions were live when an eviction hit), not immunity.
2. Four months of logs, TTL-aware
A naive count of
previous_message_not_foundover my logs gives 1,076 events / 286M tokens. Don't quote that — the server stamps the same reason on legitimate expirations after long idle, and 78% of those events were over the 1h TTL. Classifying every event by idle gap to the previous request in the same session:Daily in-TTL losses / share of that day's cache-write tokens, this week:
| day | in-TTL losses | tokens | share of day's cache writes |
|---|---|---|---|
| Aug 18 | 1 | 0.07M | 1.3% |
| Aug 19 | 0 | 0 | 0% |
| Aug 20 | 14 | 2.42M | 15.1% |
| Aug 21 | 26 | 5.84M | 26.1% |
| Aug 22 | 13 | 3.24M | 19.3% |
| Aug 23 (to 10:00) | 11 | 3.43M | 47.4% |
Week total: 71 in-TTL losses, 16.95M tokens, 17.3% of all cache writes. Longer view: near-clean until late June (weekly share 0–3%), appears early July, worst ever this week.
(Methodology:
requestIddedup is mandatory — one response is logged once per content block, naive counting inflates 2–3x. TTL runs from request start per the docs. Thediagnosticsfield first appears in my logs on Apr 26; earlier weeks are unmeasurable, not clean.)3. Minimal repro
This is the exact loop that was evicted in the 13:38 event. Healthy beats cost ~$0.09 each at 90k context; the loss beat cost $1.48.
4. Check your own account — and don't take my word for it
Stdlib-only Python. Reads only local
~/.claude/projectsmetadata, prints only aggregate numbers (no content, no session ids). Dedups byrequestIdand classifies by idle gap so legitimate expirations are not counted — only theinTTL_lossandiron<5mcolumns matter.I'd prefer you don't trust me: paste it into your own AI first, ask it to confirm it's safe and does what I claim, then have it run and sanity-check your numbers.
If
inTTL_lossis non-zero, please post the output here. If it's zero, please post that too, with your typical client and plan — the events above suggest exposure scales with how many sessions you have live when an eviction hits, so clean accounts are informative.---
Edit (Aug 23): self-check bumped v2 -> v2.1 - per @eason-chengzi's method note below, cache_miss_reason can sit on a later JSONL record of the same requestId; v2.1 back-fills the reason from later records instead of reading only the first. On my own logs this changes nothing (0 of ~152k requestIds carried the reason only on a later record), but on yours it may.
Another Windows data point, using @garaz1983-rgb's
cache_miss_reasonmethod. Posting it mainly because most of my large re-caches turned out to be legitimate TTL expiry — which I think is a useful negative result for sizing this bug — plus two events that do match the thread's signature, and independent server-side confirmation of thetools_changedmechanism from #81967.Environment
2.1.229(bulk),2.1.167,2.1.234,2.1.237claude-opus-5(495),claude-sonnet-5(271),claude-opus-4-8(150),claude-sonnet-4-6(56),claude-fable-5(27)Method: parsed the session JSONL, deduplicated by
requestId, and read real counts frommessage.usage.iterations[]— both caveats from the original report apply and materially change the totals. 468 deduplicated billed calls.Whole-session totals
| | tokens |
|---|---:|
|
cache_read_input_tokens| 151,061,111 ||
cache_creation_input_tokens| 19,498,040 || output | 417,493 |
Hit rate 88.6%. Cache creation is 11% of input tokens but, at the 2x write multiplier vs 0.1x read, roughly 69% of input spend.
cache_miss_reasondistribution (all 169 records carrying diagnostics)| type | count |
|---|---:|
|
previous_message_not_found| 133 ||
tools_changed| 12 ||
messages_changed| 9 ||
model_changed| 8 ||
system_changed| 6 ||
unavailable| 1 |Restricted to expensive events (
cache_creation>= 50k) — 61 events| reason | events | creation tokens |
|---|---:|---:|
|
previous_message_not_found| 49 | 15,396,858 ||
tools_changed| 3 | 1,232,264 ||
system_changed| 3 | 1,156,392 ||
messages_changed| 2 | 672,235 ||
model_changed| 3 | 256,973 || (no diagnostics) | 1 | 259,302 |
The part that argues against over-counting this bug
Of the 49 expensive
previous_message_not_foundevents, 47 had a preceding-request gap of >= 1 hour. At a 1h TTL those are ordinary expiry, and "the server could not locate the entry" is the expected reason string for an expired entry — not evidence of the defect. Attributing them to this bug would inflate it by ~30x.That leaves 2 genuinely short-gap events:
| gap | cache_creation | cache_read | model |
|---:|---:|---:|---|
| 33 s | 330,849 | 33,958 |
claude-opus-5|| 14 s | 128,777 | 0 |
claude-opus-4-8|The first matches the reported signature closely: seconds-scale gap, and
cache_readpinned low (33,958) rather than degrading smoothly — the same "pinned to the stable-prefix boundary" shape as the original 29,542 and @Gunther-Schulz's 20,623. Different absolute value, same behavior.So on this machine the defect is real but rare and expensive, not routine — consistent with @Gunther-Schulz's finding that only 1 of 2,569 mid-array insertions sat deep enough to cost anything.
Independent confirmation of the
tools_changedmechanism (#81967)3 expensive events (1,232,264 creation tokens) are attributed by the server to
tools_changed. #81967 inferred tools-array mutation from proxied wire captures; this is the server-side classifier naming the same cause, which I don't think has been posted in this thread yet.Relevant to that: this machine runs a Desktop build whose MCP connectors attach and detach during a session (idle-timeout driven, per
WarmLifecycleinmain.log). If connector lifecycle can mutate the tools array mid-session the way #81967 describes for LSP, that is a second, independent trigger for the same defect — and one that fires on a timer rather than on a process failure.system_changed(3 events, 1,156,392 tokens)Not discussed upthread as far as I can see. I have no mechanism for it and am not claiming one; flagging it in case it is a distinct third cause worth splitting out.
Note on
model_changed8 events. Cache keys are per-model, so these are correct behavior, but worth stating plainly for anyone reading this thread for cost advice: switching models inside one conversation discards the prefix. Five different model IDs appear in this single session.
Check I can run
If the
messages[]index-diff from @Gunther-Schulz's comment would be useful on the 2 short-gap events specifically, I can run it and post the sanitized index maps (roles, sizes, positions — no content). Say the word.Environment note: Desktop MSIX, engine
2.1.234at time of writing.Follow-up comment for issue #87966
OP here. I ran the
message.diagnostics.cache_miss_reasoncheck that @garaz1983-rgb described, against my 89 failures. The server attributes 87 of them tomessages_changed— not to a lost entry. So my case is @Gunther-Schulz's mechanism, not server-side eviction, and I want to correct my own original framing: I wrote that "the content is not diverging, the lookup is failing." The server says otherwise, and it is right to. My inference was based oncache_read + cache_creationon a failing request exactly matching the next request'scache_read— that only shows the total prompt size was as expected, which a mid-array splice preserves.Reason distribution
Session
9573dbe5, 3,660 requests, 2026-08-02 → 08-19, deduplicated byrequestId. 161 requests carry acache_miss_reason.|
cache_miss_reason.type| whole session | of my 89 failures ||---|---|---|
|
messages_changed| 101 | 87 ||
previous_message_not_found| 45 | 1 ||
unavailable| 11 | 0 ||
system_changed| 4 | 2 |I have not seen
unavailablementioned in this thread. Flagging it in case it is a distinct class.The reason maps exactly onto the boundary
cache_readpins toThis is the part I think is new. In my original report I noted
cache_readpinned to two different values and could not explain the split. The reason field explains it:| reason |
cache_readon those requests | layer that survived ||---|---|---|
|
system_changed| 25,942 / 26,239 | system prompt + tool definitions ||
messages_changed| 29,194 / 29,499 / 29,517 / 29,542 | the above + project context |The two
system_changedevents among my 89 (req_011CdpyBfHXAyV5gFFN69eF9,req_011Ce9KGdfL9kXAiytpvCT7v) are precisely the two outliers that pinned at ~26k. Every ~29.5k pin ismessages_changedorprevious_message_not_found. The ~3,300-token difference between the two bands is the project context layer.So the fallback boundary is not arbitrary — it is determined by which cache layer the mutation landed in. That is direct confirmation of @Gunther-Schulz's point that cost is set by the depth of the change rather than its size.
What is changing in
messages[]— likely removal, not insertion@Gunther-Schulz measured an insertion (a Stop-hook notification landing at index 82 of 107). My data points at removal, or at least at something that shrinks the array.
Tracking total prompt size (
cache_read + cache_creation) request over request, the first failure of every cluster carries a large negative delta:| date | first failure of cluster | Δ total context |
|---|---|---|
| 2026-08-08 15:15:33 | | −60,110 |
| 2026-08-11 15:30:58 | | −30,660 |
| 2026-08-12 18:20:25 | | −57,272 |
| 2026-08-19 18:45:53 | | −33,905 |
Smaller negative deltas continue through each cluster (−559, −2,014, −2,794, −2,438, −996, −736). Content is being dropped from the conversation mid-session, in bursts, and each burst starts a run of full re-caches.
The magnitudes (30k–60k tokens) are consistent with old tool results being cleared for context management. If that is what this is, the defect would be that the pruning is not cache-aware: dropping content from near the front of the array invalidates every breakpoint above it, so each prune costs a full-context rewrite.
That would also explain the correlation with context size — all 89 of my failures occurred above ~300k context, and none below.
One
previous_message_not_foundat a 0.3-minute gapreq_011Ce9HRBkNY1geHc57CzuoG, 2026-08-18 08:41:17, 0.3 minutes after the preceding request in the same session, 1-hour TTL tier,cache_read29,517,cache_creation537,736. Server reason:previous_message_not_found.One event, so not a pattern on my account, but it is @garaz1983-rgb's exact signature and it is well inside any TTL tier. Adding it as an independent data point.
I am classifying my other 44
previous_message_not_foundevents by idle gap now and will post the in-TTL count.Restating the ask
The excess remains 58.9M
cache_creationtokens across the 89 events, ~111.7M base-input-equivalent, for output identical to what a cache hit would have produced.With the reason field in hand, the question narrows usefully: why is Claude Code mutating the middle of its own
messages[]array mid-session, and why is that mutation not aligned to cache breakpoints?messages_changedis an accurate description of what the server observed, but nothing on my side changed — no/clear, no/compact, no config edit, no model or effort change (both constant across all 3,660 requests), no manual MCP change.Method note, matching @garaz1983-rgb's: dedup by
requestIdis mandatory, andcache_miss_reasonmay appear on only one of the several JSONL records sharing arequestId, so scan every line and take the first non-null rather than only the first record per request.Environment unchanged from the OP: Claude Code 2.1.220 native, Windows 11, Max 5x,
claude-opus-5, effortxhigh, 1-hour TTL on all writes.I hit a similar pattern — 89 full-context rewrites is brutal. When I dug into my own sessions (82 sessions, 8.
7B prompt tokens), the root cause turned out to be simpler than I expected:
93.9% of all context characters are tool output. A single grep result or a cat of a large file enters the
context and stays there for every subsequent turn. The prompt-per-turn average was 292K tokens vs 1.1K output — a
262:1 ratio.
The cache hit rate was 98.7%, which sounds efficient, but it just means the enormous payload you resend every
turn is discounted, not smaller. When the cache breaks (as in your case), the full 292K gets billed as
cache_creation instead of cache_read.
I built a small CLI to visualize this — it shows the turn-by-turn prompt-size curve and attributes context to
tool_output / assistant / user so you can see exactly which turn caused the spike:
pip install agent-cost-tracker
agent-cost analyze ~/.claude/projects/
Would be curious whether your 59M excess also correlates with a few specific tool calls early in the session
that never got evicted.
Synthesis after the three data points above. The thread now carries two distinct mechanisms, and I want them separated explicitly so that a fix for one does not close the issue on the other.
Two mechanisms, told apart by the server's own reason field
| | server reason | what actually changed | evidence in this thread |
|---|---|---|---|
| A. Client mutates
messages[]mid-array |messages_changed(system_changed/tools_changedwhen the mutation lands in a lower layer) | a Stop-hook notification inserted at index 82 of 107 (@Gunther-Schulz, measured on the wire); large negative context deltas at the start of every failure cluster, most likely non-cache-aligned pruning of old tool results (@eason-chengzi, 87 of 89 failures); MCP connector lifecycle touching the tools array (@intermap74, 3 events, cf. #81967) | fixable inside Claude Code || B. Server drops the entry inside the TTL |
previous_message_not_foundwith an idle gap far under the TTL | nothing on the client side; every live session on the account loses its entry within the same minute, across three client paths, one entry 58 s old | three accounts, below |The two labels are mutually exclusive by construction: the server only says
previous_message_not_foundwhen the prefix did match and the entry was simply gone. A and B can both be true on the same account (they are on mine), but B is not a sub-case of A. If A gets fixed in Claude Code — and it should — B stays.B on every account that has run the idle-gap classification so far
| account | client / usage shape | unique requests | in-TTL
previous_message_not_found| per-request rate | shortest gap ||---|---|---|---|---|---|
| garaz1983-rgb | Windows 11,
sdk-cli+cli, 4-8 concurrent sessions | 152,614 | 219 (158 under 5 min) | 0.14% | 58 s || intermap74 | Windows 11, Desktop MSIX, single session | 468 | 2 | 0.43% | 14 s |
| eason-chengzi | Windows 11, native CLI, single session | 3,660 | 1 (44 more pending classification) | ≥ 0.03% | 0.3 min |
The per-request rate is the same order of magnitude on all three. What makes B expensive on my account is not a higher rate — it is ~150k requests × 4-8 live sessions × 100-450k contexts, and the fact that one eviction takes every live entry at once. "Rare" is the right word per request and the wrong word per account-month.
B has kept firing while this thread was being written. Aug 23 (KST) closed at 24 in-TTL losses / 6.94M tokens / 31.7% of the day's cache writes, with two new records: at 18:05 an entry 24 seconds old was gone (previous record 58 s), and at 19:34–19:35 six sessions lost their entries within 51 seconds (1.56M tokens). After a 3.5-hour clean stretch that evening I honestly thought a fix had landed. Then this morning (Aug 24) the first real work of the day triggered 6 losses in a 14-minute window (07:58–08:12, idle gaps 7–29 min, 1.19M tokens) — quiet while idle, firing again the moment sessions are active.
I've put the full 14-day record on an interactive page — daily counts, a per-hour heatmap of losses against actual request volume (so "zero because idle" and "zero despite heavy use" are distinguishable; Aug 11 carried 4,320 requests with 1 loss while Aug 21 saw similar usage with 26), every batch-eviction window, and the loss-anatomy numbers above: https://claude.ai/code/artifact/286a2049-a60d-4840-a39a-0a0be1374779
One more figure on why B's per-request rarity understates it. Chaining my 105 in-TTL losses of the last 14 days into eviction windows (consecutive losses ≤3 min apart): 61 windows, of which 19 hit two or more sessions at once. Those batch windows are 31% of occurrences but 62% of the re-billed tokens (15.3M of 24.7M) — an average batch window costs 807k tokens, 3.6× a single-session one, and the four most expensive windows are all six-session simultaneous evictions. The blast radius of one server event scales with everything you have live.
Method notes
requestId, not the first): I checked my logs — 0 of ~152k requestIds have the reason only on a later record, so the numbers above are unaffected. It is still the right way to scan; the self-check in my comment above now takes the first non-null reason across all records of a requestId (v2.1).messages[]shows no mid-array change on those two, that is the cleanest separation of B from A anyone has posted.The ask, narrowed
Two fixes, not one:
<system-reminder>blocks already ride.ephemeral_1htier are dropped inside the TTL, account-wide and in a batch, and credit the re-billed tokens. I have a support ticket open for the billing side; this thread is the evidence it points to.