cd /news/large-language-models/prompt-caching-cut-my-claude-bill-by… · home topics large-language-models article
[ARTICLE · art-54484] src=dev.to ↗ pub= topic=large-language-models verified=true sentiment=↑ positive

Prompt Caching Cut My Claude Bill by 80%: The Mistakes That Were Costing Me

A developer discovered that prompt caching was not working in their Claude API calls due to three silent bugs: a dynamic date in the system prompt, unsorted JSON keys, and per-user tool lists. After fixing these issues by moving the date to the user message, sorting JSON keys, and using a stable tool list, their cache hit rate went from zero to consistent, reducing their input token bill by approximately 80%.

read3 min views1 publishedJul 10, 2026

I was paying full price for input tokens I was sending over and over. A large system prompt, a fixed tool list, the same reference docs on every request. Prompt caching should have made those cheap, except I had three silent bugs that meant nothing was actually caching. Here is what I found when I finally checked the numbers, and how I got my hit rate from zero to consistent.

Prompt caching is a prefix match. Any byte change anywhere in the prefix invalidates everything after it. The cache key is the exact bytes of the rendered prompt up to each breakpoint.

Render order is fixed: tools

, then system

, then messages

. So your most stable content has to physically come first, and anything that changes per request has to come last. Get the ordering right and caching mostly works for free. Get it wrong and no amount of cache_control

markers will save you.

The response usage

object tells you the truth:

console.log(response.usage.cache_creation_input_tokens); // written to cache (~1.25x cost)
console.log(response.usage.cache_read_input_tokens);     // served from cache (~0.1x cost)
console.log(response.usage.input_tokens);                // full price, uncached

I ran the same request twice and cache_read_input_tokens

was zero both times. If the prefix were identical, the second request should have read the cache. Zero reads means a silent invalidator was changing my prefix between requests.

This was the big one:

// WRONG: the date changes every request, so the prefix is never stable
const system = `You are a security auditor. Current date: ${new Date().toISOString()}.`;

The date is at the front of the prefix, so it invalidated everything. I did not even need the timestamp in the system prompt. I moved it into the user message, which sits after the cached prefix and invalidates nothing before it.

I was serializing a config object into the system prompt without sorting keys:

// WRONG: key order can vary, changing the bytes
const system = `Config: ${JSON.stringify(config)}`;
// RIGHT
const system = `Config: ${JSON.stringify(config, Object.keys(config).sort())}`;

Same data, different bytes, different cache key. JavaScript does not guarantee object key order across all code paths, and iterating a Set

is worse. Sort it, or do not put it in the prefix at all.

I built the tool list dynamically based on the user. Tools render at position 0, so a per-user tool set means nothing caches across users:

// WRONG: different users get different tool arrays at position 0
tools: buildToolsForUser(user),
// RIGHT: a stable, deterministic tool list, sorted by name
tools: ALL_TOOLS, // gate behavior with tool_choice or message content instead

Once the prefix was actually stable, I added one cache_control

marker on the last system block. That caches tools plus system together:

const response = await client.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 16000,
  system: [
    {
      type: "text",
      text: LARGE_STABLE_SYSTEM_PROMPT,
      cache_control: { type: "ephemeral" },
    },
  ],
  messages: [{ role: "user", content: userQuestion }],
});

Cache reads cost about 0.1x base input price. Cache writes cost 1.25x for the 5-minute TTL. So you break even on the second request and win on every one after. For my auditor, where the system prompt and the contract-analysis instructions are identical across every call in a session, the savings were dramatic: the uncached portion shrank to just the contract source and the question.

My bill on the input side dropped roughly 80%, because the part that was constant (the bulk of the tokens) was finally being served from cache instead of paid for fresh every time.

If cache_read_input_tokens

is stuck at zero, grep your prompt-building code for:

Date.now()

, new Date()

, time.time()

anywhere in the prefixcrypto.randomUUID()

or request IDs early in the contentJSON.stringify

without sorted keys, or iterating a Set

Fix those, add one breakpoint on the last stable block, and watch the read tokens climb. The bytes have to be identical. That is the whole game.

── more in #large-language-models 4 stories · sorted by recency
── more on @claude 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/prompt-caching-cut-m…] indexed:0 read:3min 2026-07-10 ·