◉CLAUDE LABJP
●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 43 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 43 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-07-19Advanced

When Prompt Caching Was On but the Bill Didn't Move — Field Notes on Instrumenting cache_read to Close the Hit-Rate Gap

You added cache_control but your Claude API bill barely changed. Before guessing at fixes, record cache_read and cache_creation on every request and split the hit-rate gap into three causes: non-deterministic prefixes, TTL expiry, and sub-threshold blocks.

prompt-caching15cache-hit-ratecost-optimization31observability21api39

✦ Premium Article

I opened last month's invoice and paused. I had added cache_control to my system prompt three weeks earlier. I never checked the hit rate — I just assumed "that's a 90% discount now." But the input-token charges had barely moved.

That was the moment it finally landed: caching isn't a binary of on or off. It was on. It just wasn't being read. And that gap only shows up in the invoice — you cannot see it by staring at the code.

These are my notes from raising the cache hit rate on a summarization batch I run as an indie developer, from the low 30s to nearly 90%. This isn't a tour of clever optimization tricks. Measure first, name the leak, then move one breakpoint at a time. It's a record of that unglamorous back-and-forth.

"Enabled" and "working" are two different states

Prompt caching stores the prefix — the leading portion of system, tools, and messages — on the server. When a later request reuses the same prefix, that portion is billed at roughly a tenth of the normal input rate.

The catch is that writing cache_control guarantees nothing at the moment you write it. Whether the cache was actually read only appears in the response usage. Not looking there was the whole shape of my three-week blind spot.

usage fieldMeaningBilling (relative to normal input)
cache_creation_input_tokensTokens written to the cache (this call missed)~1.25x (5-minute cache)
cache_read_input_tokensTokens read from the cache (this call hit)~0.1x
input_tokensNormal, non-cached input1x

Reading it is simple. If cache_read_input_tokens comes back non-trivial on most calls, caching is working. If cache_creation_input_tokens keeps firing instead, the cache is being built and thrown away over and over. "Added but not working" is almost always this state.

Add one thin measurement layer first

Before I started guessing at causes, I inserted a thin layer that records usage on every request. Ahead of any optimization, I wanted to know where I actually stood, in numbers.

import logging
from dataclasses import dataclass, field
 
logger = logging.getLogger("cache")
 
@dataclass
class CacheProbe:
    """Minimal harness that logs cache_read / cache_creation per request."""
    requests: int = 0
    hits: int = 0            # count of calls where cache_read > 0
    rewrites: int = 0        # count of calls where cache_creation > 0
    read_tokens: int = 0
    write_tokens: int = 0
    last_prefix_fingerprint: str | None = field(default=None)
 
    def record(self, usage) -> None:
        self.requests += 1
        read = getattr(usage, "cache_read_input_tokens", 0) or 0
        write = getattr(usage, "cache_creation_input_tokens", 0) or 0
        self.read_tokens += read
        self.write_tokens += write
        if read > 0:
            self.hits += 1
        if write > 0:
            self.rewrites += 1
 
    @property
    def hit_rate(self) -> float:
        return (self.hits / self.requests * 100) if self.requests else 0.0
 
    @property
    def rewrite_rate(self) -> float:
        return (self.rewrites / self.requests * 100) if self.requests else 0.0
 
    def snapshot(self) -> str:
        return (
            f"req={self.requests} hit={self.hit_rate:.1f}% "
            f"rewrite={self.rewrite_rate:.1f}% "
            f"read_tok={self.read_tokens:,} write_tok={self.write_tokens:,}"
        )

It's a layer that just calls record(). I wanted to see one thing: is the rewrite rate stuck high? Measured on the summarization batch, hit rate was 34.8% and rewrite rate was 61.2%. More than half of all requests were rebuilding the cache. That put a number on why the bill wasn't dropping.

Why make rewrite rate the lead metric instead of hit rate? Because hit rate alone hides the cause. It tells you a call missed, but not whether the prefix shifted or whether the cache timed out. Seeing how often — and at what spacing — writes happen makes that split fall out quickly.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A minimal harness that pulls cache_read and cache_creation from usage and logs hit rate and rewrite rate on every request
✦A measurement-first way to split a low hit rate into three distinct causes: non-deterministic prefix, TTL expiry, and sub-threshold blocks
✦How to walk one cache breakpoint backward at a time to locate the block that keeps re-triggering cache_creation
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ API & SDK2026-09-07
A 75% cheaper cache read moved one bill by 29% and another by 2.8%
Fable 5.1 and Mythos 5.1 changed the cache read multiplier from 0.1 to 0.025. Here is how to estimate your own saving from your token mix, and how to fix code that hard-codes the multiplier.
⬡ API & SDK2026-04-29
Production Semantic Cache for Claude API — Similarity Thresholds, Pollution Defense, and What to Track
A production playbook for adding a semantic cache in front of Claude API — threshold tuning, multi-tenant isolation, pollution prevention, fallbacks, how it stacks with Anthropic's prompt caching, and the metrics that actually prove it works.
⬡ API & SDK2026-04-26
How I Cut My Claude API Bill in Half With Prompt Caching
Done right, Anthropic's prompt caching can roughly halve your monthly API spend on workloads with long, repeated system prompts. Here is the design playbook I use after six months in production, including the arithmetic that links hit rate to the bill and the break-even points (about 22% on the 5-minute TTL, 53% on the 1-hour TTL).