{"slug": "prompt-caching-cut-my-claude-bill-by-80-the-mistakes-that-were-costing-me", "title": "Prompt Caching Cut My Claude Bill by 80%: The Mistakes That Were Costing Me", "summary": "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%.", "body_md": "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.\n\nPrompt 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.\n\nRender order is fixed: `tools`\n\n, then `system`\n\n, then `messages`\n\n. 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`\n\nmarkers will save you.\n\nThe response `usage`\n\nobject tells you the truth:\n\n```\nconsole.log(response.usage.cache_creation_input_tokens); // written to cache (~1.25x cost)\nconsole.log(response.usage.cache_read_input_tokens);     // served from cache (~0.1x cost)\nconsole.log(response.usage.input_tokens);                // full price, uncached\n```\n\nI ran the same request twice and `cache_read_input_tokens`\n\nwas 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.\n\nThis was the big one:\n\n``` js\n// WRONG: the date changes every request, so the prefix is never stable\nconst system = `You are a security auditor. Current date: ${new Date().toISOString()}.`;\n```\n\nThe 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.\n\nI was serializing a config object into the system prompt without sorting keys:\n\n``` js\n// WRONG: key order can vary, changing the bytes\nconst system = `Config: ${JSON.stringify(config)}`;\n// RIGHT\nconst system = `Config: ${JSON.stringify(config, Object.keys(config).sort())}`;\n```\n\nSame data, different bytes, different cache key. JavaScript does not guarantee object key order across all code paths, and iterating a `Set`\n\nis worse. Sort it, or do not put it in the prefix at all.\n\nI 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:\n\n```\n// WRONG: different users get different tool arrays at position 0\ntools: buildToolsForUser(user),\n// RIGHT: a stable, deterministic tool list, sorted by name\ntools: ALL_TOOLS, // gate behavior with tool_choice or message content instead\n```\n\nOnce the prefix was actually stable, I added one `cache_control`\n\nmarker on the last system block. That caches tools plus system together:\n\n``` js\nconst response = await client.messages.create({\n  model: \"claude-opus-4-8\",\n  max_tokens: 16000,\n  system: [\n    {\n      type: \"text\",\n      text: LARGE_STABLE_SYSTEM_PROMPT,\n      cache_control: { type: \"ephemeral\" },\n    },\n  ],\n  messages: [{ role: \"user\", content: userQuestion }],\n});\n```\n\nCache 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.\n\nMy 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.\n\nIf `cache_read_input_tokens`\n\nis stuck at zero, grep your prompt-building code for:\n\n`Date.now()`\n\n, `new Date()`\n\n, `time.time()`\n\nanywhere in the prefix`crypto.randomUUID()`\n\nor request IDs early in the content`JSON.stringify`\n\nwithout sorted keys, or iterating a `Set`\n\nFix 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.", "url": "https://wpnews.pro/news/prompt-caching-cut-my-claude-bill-by-80-the-mistakes-that-were-costing-me", "canonical_source": "https://dev.to/pavelespitia/prompt-caching-cut-my-claude-bill-by-80-the-mistakes-that-were-costing-me-39dn", "published_at": "2026-07-10 16:29:26+00:00", "updated_at": "2026-07-10 16:42:49.546357+00:00", "lang": "en", "topics": ["large-language-models", "ai-tools", "developer-tools"], "entities": ["Claude", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/prompt-caching-cut-my-claude-bill-by-80-the-mistakes-that-were-costing-me", "markdown": "https://wpnews.pro/news/prompt-caching-cut-my-claude-bill-by-80-the-mistakes-that-were-costing-me.md", "text": "https://wpnews.pro/news/prompt-caching-cut-my-claude-bill-by-80-the-mistakes-that-were-costing-me.txt", "jsonld": "https://wpnews.pro/news/prompt-caching-cut-my-claude-bill-by-80-the-mistakes-that-were-costing-me.jsonld"}}