[BUG] Prompt cache regression in --print --resume since v2.1.69(?): cache_read never grows, ~20x cost increase
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?
Summary
--print --resume sessions stopped caching conversation turns between API calls starting around v2.1.69. Only Claude Code's internal system prompt (~14.5k tokens) is cached; all conversation history is cache_created from scratch on every message. This causes a ~20x cost increase per message compared to v2.1.68.
Environment
- Platform: Ubuntu (Hetzner VPS)
- Use case: Discord bot using
claude --print --model <model> --resume <session-id> --output-format stream-json --verbosewith prompts piped via stdin - Tested models:
claude-opus-4-6[1m],opus,claude-opus-4-5-20251101
The regression is version-dependent, not model-dependent.
Suspect
Something in newer updates after 2.1.68 may have inadvertently broken cache breakpoint placement for --print --resume sessions.
Workaround
Pinned to v2.1.68 (npm install -g @anthropic-ai/claude-code@2.1.68).
What Should Happen?
Expected behavior (v2.1.68)
cache_read grows as conversation accumulates, cache_create drops to a small delta (~800 tokens):
Message 1: cache_read=13,997 cache_create=22,946 cost=$0.15 (cold start)
Message 2: cache_read=32,849 cache_create=4,636 cost=$0.05
Message 3: cache_read=36,846 cache_create=879 cost=$0.03
Message 4: cache_read=37,295 cache_create=802 cost=$0.02
Actual behavior (v2.1.76 and likely earlier versions after v2.1.68)
cache_read is stuck at ~14.5k (Claude Code's system prompt only), cache_create equals the full conversation size and grows every message:
Message 1: cache_read=14,569 cache_create=54,437 cost=$0.35
Message 2: cache_read=14,569 cache_create=55,084 cost=$0.35
Message 3: cache_read=14,569 cache_create=55,512 cost=$0.35
Message 4: cache_read=14,569 cache_create=55,733 cost=$0.36
Message 5: cache_read=14,569 cache_create=55,954 cost=$0.36
The conversation turns are never reused from cache between calls. Only Claude Code's internal system prompt (~14.5k tokens) caches successfully.
Error Messages/Logs
## Testing matrix
All tests used fresh session UUIDs and back-to-back messages (well within the 5-minute cache TTL):
| Version | Model | Context | cache_read grows? | Steady-state cost/msg |
|---------|-------|---------|-------------------|----------------------|
| 2.1.68 | `opus` | 200k | **Yes** | ~$0.02 |
| 2.1.68 | `claude-opus-4-6[1m]` | 1M | **Yes** | ~$0.02 |
| 2.1.76 | `opus` | 200k | **No (stuck at 14.5k)** | ~$0.04-0.40 (grows) |
| 2.1.76 | `claude-opus-4-6[1m]` | 1M | **No (stuck at 14.5k)** | ~$0.35-0.40 |
| 2.1.76 | `claude-opus-4-5-20251101` | 200k | **No (stuck at 14.5k)** | ~$0.04-0.40 (grows) |
Steps to Reproduce
Reproduction
- Run
claude --print --resume <session-id> --output-format stream-json --verbosewith a prompt via stdin - Send 3+ messages to the same session
- Observe
cache_read_input_tokensandcache_creation_input_tokensin the stream-jsonresultoutput
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
2.1.68
Claude Code Version
2.1.76
Platform
Other
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Other
Additional Information
This report (including the testing matrix) was written by Claude Code during a debugging session.
25 Comments
Thanks for the detailed investigation — the version bisection and cost comparison tables are very helpful.
Analysis
The behavior you're describing (only ~14.5k tokens cached = system prompt only) strongly suggests that the cache breakpoint placement changed in v2.1.69+. Anthropic's prompt caching requires cache control markers at specific positions in the messages array. If those markers moved or were removed for
--print --resumesessions, the API would treat the full conversation as new content on every call.The fact that
cache_readis stuck at exactly the system prompt size confirms the cache breakpoint is only being placed on the system message, not on conversation turn boundaries.Workaround
Until this is fixed upstream, pinning to v2.1.68 as you're doing is the right call. If you need features from newer versions, you can run two installations side-by-side:
Cost mitigation if you must use latest
If pinning isn't an option, you can reduce the damage by keeping
--resumeconversations short and starting fresh sessions more frequently. The cost scaling is roughly:So shorter sessions with more frequent
--resumeto new session IDs will keep the per-message cost lower, though this sacrifices the conversation continuity benefit.For the team
This is likely a one-line fix — wherever cache breakpoints are placed on conversation turns for interactive sessions, the same logic needs to apply to
--print --resumesessions. The regression window (v2.1.68 → v2.1.69) should make it easy to bisect in the source.I've found the problem you've encountered, it's due to history rewrite during resumption of a request. Here's a summary of my findings generated by claude code:
Root Cause Analysis: Cache regression on
--resumesince v2.1.69TL;DR
The
deferred_tools_deltaattachment type was introduced in v2.1.69 (not present in v2.1.68). This causesmessages[0]to have fundamentally different content on fresh sessions vs resumed sessions, breaking the prompt cache prefix match.Technical Details
We reverse-engineered the cache behavior by capturing full API request payloads via MITM proxy across versions 2.1.68 through 2.1.87.
How
messages[0]is built — the asymmetryFresh session (first request):
Resumed session:
This creates a 13.4KB vs 352B mismatch in
messages[0]— completely different prefix.Three independent cache-breaking effects
| # | Cause | Fresh session | Resume | Breaks cache? |
|---|-------|--------------|--------|---------------|
| 1 |
messages[0]content | 4 reminders (13KB) | 1 reminder (352B) | YES — prefix changed || 2 |
system[0]cc_version hash | e.g.2.1.87.7b6| e.g.2.1.87.c02| YES — derived from msgs[0] || 3 |
cache_controlmarker position | On msgs[0] block[4] | On msgs[last] | YES — breakpoint moved |Cause #1 is the root cause. Cause #2 is a consequence (#1 changes
messages[0], and thecc_versionbilling suffix is computed by hashing characters at positions 4, 7, and 20 of the first user message text viaC46()/qT8()).Why v2.1.68 worked
deferred_tools_deltadoes not exist in v2.1.68:Without
deferred_tools_delta,messages[0]on both fresh and resumed sessions contained only the AU$ context (CLAUDE.md + currentDate) — identical content, identical prefix, cache hit.Subsequent versions made it worse
Each version after 2.1.69 added more content to the
deferred_tools_deltaattachment (more deferred tools, MCP server instructions growth), widening the gap between fresh and resumedmessages[0].What caches and what doesn't
| Transition | Cache result | Why |
|-----------|-------------|-----|
| Fresh turn N → turn N+1 | HIT | msgs[0] stable within session |
| Fresh → Resume | MISS | msgs[0] completely different (13KB → 352B) |
| Resume → next turn (same process) | HIT | msgs[0] stable after resume |
| Resume → Resume (new process, e.g.
--print) | Partial HIT | system cached (~11k), messages = cache_creation |The
--print --resumeuse case from this issue is hit hardest because each invocation is a new process, so the cache from the previous resume's messages is never reused for system-reminder content.Suggested fixes (any one would help)
h2("resume")should inject attachments intomessages[0](same position as fresh session), not append tomessages[N]system[]parameter instead ofmessages[]— they're static context, not conversationrw9()should skipisMetamessages when finding the first user message for billing suffix hash — currently it hashes the system-reminder content instead of the actual user promptmessages[0]to match fresh session layout (prepend deferred tools + MCP + skills before AU$ context)Methodology
mitm-addon.pyvia mitmproxy)Just being pedantic, but if it's O(1) and O(n) per message wouldn't that just be O(n) and O(n^2) complexity overall.
Yeah, call me crazy but I feel like this "bug" isn't getting patched anytime soon lol
I hope not, that's a deal breaker for me, I'm not paying 100$ a month for 7 prompts every 5 hours. That's litteraly the limit for me right now with this bug.
Thanks to @jmarianski for the excellent root cause analysis — the MITM proxy capture and
messages[0]asymmetry breakdown made this patch possible.I used CC to analyze the latest v2.1.88 bundle based on those findings and wrote a monkey-patch to fix the resume cache regression. Tested with the
test_cache.pyscript from Reddit:Test Results — v2.1.88
| Metric | Binary | npm (unpatched) | npm (patched) |
|--------|-------:|----------------:|--------------:|
| Sentinel Replacement | ✗ ACTIVE (
00000→42216) | ✓ Not active | ✓ Not active || Fresh cache_read | 28,343 | 27,460 | 22,468 |
| Fresh cache_creation | 7,400 | 5,091 | 10,088 |
| Resume cache_read | 10,521 | 11,234 | 11,234 |
| Resume cache_creation | 7,446 | 5,121 | 5,124 |
| Consecutive cache_read | 10,521 | 11,234 | 16,328 ↑ |
| Consecutive cache_creation | 7,485 | 5,206 | 95 ↓ |
| Resume Cache | ✗ BROKEN | ✗ BROKEN | ✓ WORKING |
| Consecutive Resume | ⚠ NOT GROWING | ⚠ NOT GROWING | ✓ GROWING |
Patch
The fix injects a shim (
_pRc) at theVp8(F,z)call site in the main loop (M3Y). It relocatesdeferred_tools_delta,mcp_instructions_delta,agent_listing_delta, andskill_listingattachment messages from the end of the message array to right aftermessages[0]. This allowsbP()to merge them intomessages[0], matching the fresh session layout and restoring the cache prefix match.Feel free to try it yourself — the patch script validates targets before modifying anything, so it won't corrupt your install if the identifiers don't match.
3-31 edited: scripts have been updated to fix the versioning and false positive bugs.
I also asked Claude to make a patch for this bug.
See <https://github.com/lixiangwuxian/cc_cache_fix?tab=readme-ov-file#usage>.
Since this is only a hook on sending message, this should work on older versions and future versions as long as the relative message struct has not changed much. (if then they did not fix the bug yet)
As always, please read the patch before executing.
@VictorSun92 Thanks for the patch. I have trouble validating the fix via the test script though.
If I install
2.1.68(vianpm install -g @anthropic-ai/claude-code@2.1.68), the test script shows* No known cache bugs detectedas expected.If I install
2.1.88, the patch process has this output:These are the test results (with
python3 test_cache.py "npx @anthropic-ai/claude-code"):Do you know if I'm doing something wrong?
@achton You're not doing anything wrong — the patch IS working! Both issues are cosmetic bugs in scripts. Sorry about the confusion.
1. Version detection (
v2020-06-01)The regex
VERSION:"([^"]+)"is too greedy — it matches the Anthropic API version string (e.g.API_VERSION:"2020-06-01") instead of the build version. The patch still applied correctly since it validated the actual code targets before modifying anything.2. Test false positive (
RESUME CACHE: BROKEN)Your numbers actually show the patch is working perfectly:
The test script judges
resume_ok = consecutive_grows AND consecutive_creation_drops. But when resumecache_creationis already near zero (22), the consecutive value can't drop below22 * 0.8 = 17.6, so the check fails. This is a false positive — I hit the same issue in my own testing.Updated scripts
I've updated both scripts to fix these issues:
cache-fix.patch.js — version detection now uses the build info object pattern instead of a generic regex.
test_cache.py — detection logic now also checks for
cache_creation < 200as an absolute threshold (if creation is already this low, the cache is clearly working).@VictorSun92 Still identifying one bug fwiw:
Not sure if it matters, but I reinstalled with
npm install -g @anthropic-ai/claude-codeas the local version doesn't expose cli.js. Still using .claude though, just triggering the binary directly (i.e.~/.nvm/versions/node/v24.14.1/bin/claude).Ah, hit the same when running
/branchtoo https://github.com/anthropics/claude-code/issues/41284, likely the same rootNode.js fetch hook fix for v2.1.87 standalone binary users (tested, working)
Building on @jmarianski's root cause analysis and @VictorSun92's patch approach, here's a version-independent fix that works via Node.js
--importpreload — no binary patching, no extraction, survives auto-updates.Test results (v2.1.87, Linux)
| Step | Standalone binary | npm + fetch hook |
|------|------------------:|------------------:|
| Fresh | 0% cache hit | 79.0% |
| Resume | 0% | 81.0% |
| Consecutive | 0% cache hit | 81.2% |
The standalone binary had 0% cache hits on ALL steps (not just resume). The npm version with the hook restores proper caching across the board.
How it works
The hook intercepts
globalThis.fetchand, on every/v1/messagesrequest, checks if it's a resume scenario by comparingmessages[0](first user message) andmessages[N](last user message). On resume, hooks/skills/MCP/deferred-tools blocks land at the END instead of the BEGINNING. The hook relocates them tomessages[0], matching fresh session layout and restoring the cache prefix match.Setup (3 files)
1.
~/.claude/cache-fix-preload.mjs— the fetch hook:2.
~/.local/bin/claude-fixed— wrapper script:3. Activate:
Why this approach
来信收悉,我将择期查看。顺祝六时如意,四季安康。
@dhollman Why has this issue been closed? It's not yet resolved in 2.1.89 from what I've checked.
EDIT: Just to clarify, I've seen some changelogs, but the issue itself isn't fixed, just some interpretations of it. You can verify it with this script I've posted on reddit (sorry I didn't link it here in this thread: https://gitlab.com/treetank/cc-diag/-/raw/c126a7890f2ee12f76d91bfb1cc92612ae95284e/test_cache.py). I can only hope that changes also affect /resume command.
Interesting bit of a exchange with Claude:
And this:
Interesting note: Running this interceptor and starting at 0% current session usage, I compacted two 1.9M token contexts. This action resulted in 32% usage.
This is probably rooted in the same bug(s) that cause the cold-start --resume consumption.
still waiting for them to patch the hole in my wallet
From my research this issue is ALMOST fixed in 2.1.90. I'm testing it right now, and apparently the very first resume in the conversation may still be failing and recreating cache. I'll post results when I have them.
See this comment.
this is not fixed btw. still doing cache create without cache read ever growing in 2.1.89.
@cinniezra try 👍and subscribe on https://github.com/anthropics/claude-code/issues/42052
v2.1.97 update — resume cache regression still present
Automated testing confirms the block scatter bug reported in this issue is still present in v2.1.97. On resume, attachment blocks (skills, deferred tools, MCP, hooks) scatter out of
messages[0], breaking the cache prefix and forcing ~5,200 tokens of unnecessary cache creation per turn.| Turn | Without fix | With fix |
|------|-------------|----------|
| Resume T1 | 5,204 create / 20,939 read (80%) | 0 create / 26,143 read (100%) |
| Resume T2 | 5,220 create / 20,939 read (80%) | 0 create / 26,159 read (100%) |
| Resume T3 | 18 create / 26,159 read (99.9%) | 0 create / 26,177 read (100%) |
Our interceptor (
npm install -g claude-code-cache-fix) eliminates the regression — zero cache creation across all resume turns. v1.5.1 also adds deferred tools sorting, content pinning for whitespace jitter, and a cost report tool.Full test report: docs/v2.1.97-cache-test-report.md
This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.