Skip to main content
Crashbytes logoCrashbytes
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Browse Articles
HomeArticlesByte Sized ExamplesOpen SourceServicesAboutContact
Network
Theme
Browse Articles
Crashbytes logoCrashbytes

Expert insights on web development, technology trends, and programming best practices. Learn from real-world experiences and cutting-edge techniques that help you build better software.

Follow Us

Our Sites

  • ๐Ÿ”ฎ Predictions
  • ๐Ÿ“ฐ Breaking News
  • ๐ŸŽจ AI Art
  • ๐Ÿ“– Short Stories
  • View All โ†’
  • Products โ†’

Sitemap

  • Home
  • All Articles
  • Open Source
  • Services
  • About Us
  • Contact
  • Donate Compute

Popular Topics

  • Serverless
  • Cloud Architecture
  • DevOps
  • Kubernetes
  • Platform Engineering

Resources

  • Privacy Policy
  • Terms of Service
  • Sitemap
  • RSS Feed
  • PGP Key

Stay Updated

Get the latest articles, tutorials, and insights delivered to your inbox. Join our community of developers and never miss an update.

ยฉ 2021-2026 Crashbytesยฎ by Blackhole Software, LLC. All rights reserved.
| Reg. U.S. Pat. & Tm. Off.

Made for the developer community

  1. Home
  2. /
  3. Articles
  4. /
  5. Cut LLM Token Costs with Anthropic Prompt Caching in TypeScript
TutorialJuly 13, 202627 min readโ€ข By Michael Eakins

Cut LLM Token Costs with Anthropic Prompt Caching in TypeScript

A hands-on TypeScript tutorial on Anthropic prompt caching. Build a cache-aware request planner, audit for silent invalidators, and measure real savings from the usage fields, offline and fully tested.

Quick Takeaways

What you'll learn in this article

27 min read
Intermediate
  • 1

    Model facts โ€” pricing per million tokens, the cache multipliers, and the minimum cacheable prefix per model, in one place you can trust.

  • 2

    Plan โ€” take a stable system prompt, a deterministic tool list, a large context blob, and a volatile question, and produce a request whose cache boundary is in the correct place.

  • 3

    Audit โ€” scan the stable prefix for the specific patterns that silently invalidate a cache, and serialize tool definitions deterministically.

  • 4

    Measure โ€” read the usage fields off a response and compute the cache hit rate and the realized dollar savings versus paying full price.

  • 5

    Wire โ€” a thin client that composes the four above around any object shaped like the Anthropic SDK, so it is trivial to test and trivial to adopt.

Keep reading for detailed implementation, code examples, and real-world results

Most teams discover prompt caching the same way: the monthly bill arrives, and the finance channel wants to know why a chatbot that answers short questions is metering tens of thousands of input tokens per call. The answer is almost always the same. Every request ships the same fat preamble โ€” a long system prompt, a tool catalog, a retrieved knowledge base โ€” and every request pays full price for it, over and over, even though those tokens never change between calls.

Prompt caching is the fix, and on paper it is trivial: mark the stable part of your prompt, and the provider serves it from a warm cache at roughly a tenth of the price on subsequent requests. In practice, most teams either never turn it on, or turn it on and quietly break it โ€” a stray timestamp in the system prompt, a tool list that serializes in a different order each boot, and the cache read rate sits at zero while everyone assumes it is working.

This tutorial builds a small, focused TypeScript library that makes prompt caching correct by construction and, just as importantly, measurable. It plans requests so the cache boundary lands in the right place, audits prompts for the handful of mistakes that silently invalidate the cache, and turns the raw usage numbers a response comes back with into a dollars-and-cents savings report. Everything is dependency-injected and tested offline, so you can run the whole thing without an API key. The complete, runnable project lives in the CrashBytes ByteSizedExamples repository.

We build against Anthropic's prompt caching because it gives you explicit control over where the cache boundary sits โ€” you place a breakpoint, you can reason about exactly what gets cached. The same discipline applies to any provider with prefix caching; the numbers and the cache_control syntax are Anthropic's, but the design lessons transfer directly.

What you will build

The library has five responsibilities, and each maps to a single module you will implement one at a time:

Naive request vs cache-aware request

Stable preambleNaive: system prompt, tools, and context re-sent at full price on every call
Stable preambleCache-aware: marked with a breakpoint once, served from cache at roughly one tenth the price
Cache boundaryNaive: no boundary, so nothing is reusable across requests
Cache boundaryCache-aware: boundary placed on the last stable block, before the volatile question
DeterminismNaive: tool JSON order and stray timestamps change the prefix bytes every call
DeterminismCache-aware: prefix audited and serialized deterministically so the bytes match
VisibilityNaive: nobody knows the cache hit rate until the bill lands
VisibilityCache-aware: every response yields a savings report in dollars
  • Model facts โ€” pricing per million tokens, the cache multipliers, and the minimum cacheable prefix per model, in one place you can trust.
  • Plan โ€” take a stable system prompt, a deterministic tool list, a large context blob, and a volatile question, and produce a request whose cache boundary is in the correct place.
  • Audit โ€” scan the stable prefix for the specific patterns that silently invalidate a cache, and serialize tool definitions deterministically.
  • Measure โ€” read the usage fields off a response and compute the cache hit rate and the realized dollar savings versus paying full price.
  • Wire โ€” a thin client that composes the four above around any object shaped like the Anthropic SDK, so it is trivial to test and trivial to adopt.

Prerequisites

You will want Node.js 20 or newer, npm, and a working knowledge of TypeScript โ€” generics, discriminated unions, and strict mode should feel routine. No Anthropic API key is required to follow along or to run the test suite; the demo degrades gracefully to an offline simulation when no key is present. If you have never wired up a resilient LLM client before, the companion resilient multi-provider LLM client in TypeScript is a good primer on the request-construction patterns we build on here.

Project setup

Clone the examples repository and move into the project directory:

git clone https://github.com/CrashBytes/ByteSizedExamples.git
cd ByteSizedExamples/prompt-caching-cost-optimizer-typescript
npm install
cp .env.example .env   # optional โ€” the demo runs offline without a key
npm start

The project is a standard ESM TypeScript package. The layout separates the pure logic (planning, auditing, cost math) from the thin I/O layer (the client and the demo entry point), which is what lets the whole thing be tested without a network:

prompt-caching-cost-optimizer-typescript/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ models.ts            # pricing, multipliers, minimum cacheable prefix
โ”‚   โ”œโ”€โ”€ types.ts             # shared types for plans, blocks, and usage
โ”‚   โ”œโ”€โ”€ cache-planner.ts     # places the cache boundary correctly
โ”‚   โ”œโ”€โ”€ invalidator-audit.ts # finds silent cache invalidators
โ”‚   โ”œโ”€โ”€ savings.ts           # turns usage numbers into dollars
โ”‚   โ”œโ”€โ”€ client.ts            # composes the above around the Anthropic SDK
โ”‚   โ””โ”€โ”€ index.ts             # runnable demo (offline or live)
โ””โ”€โ”€ tests/
    โ”œโ”€โ”€ cache-planner.test.ts
    โ”œโ”€โ”€ invalidator-audit.test.ts
    โ”œโ”€โ”€ savings.test.ts
    โ””โ”€โ”€ client.test.ts
Advertisement

How prompt caching actually works

Before writing a line of the library, you need the mental model, because every design decision falls out of one invariant.

Prompt caching is a prefix match. The provider hashes the exact bytes of the rendered prompt up to each cache breakpoint. On the next request, if that prefix is byte-for-byte identical, the matching span is served from cache. Any change anywhere in the prefix โ€” a single character โ€” invalidates the cache from that point forward. The render order is fixed: tools first, then the system prompt, then the messages. A breakpoint on the last system block therefore caches the tools and the system prompt together.

That single fact drives the whole design. Stable content must physically come first, before any breakpoint. Volatile content โ€” the user's actual question, a per-request timestamp, a session identifier โ€” must come after the last breakpoint, or it poisons everything ahead of it.

The economics are what make the effort worthwhile. Reading cached tokens costs roughly one tenth of the base input price. Writing them to the cache costs more than an uncached token on the request that does the writing โ€” about 1.25 times the base price for the default five-minute cache, or twice the base price for the one-hour cache. That write premium is the catch most cost models miss: the first request is more expensive, not less, and caching only pays off once you get enough reads to amortize that write.

Price multiplier relative to base input token price

Price multiplier relative to base input token price
kindmult
Cache read0.1
Uncached input1
Cache write 5m TTL1.25
Cache write 1h TTL2

There is one more constraint that trips people up: a prefix has to clear a minimum size before the provider will cache it at all. Below that threshold you get no cache write, no error, and no warning โ€” just full-price tokens forever. The threshold is model-dependent, and a prompt that caches on one model will silently refuse to cache on another.

Minimum cacheable prefix by model (tokens)

Minimum cacheable prefix by model (tokens)
modelminTokens
Opus 4.84096
Sonnet 52048
Haiku 4.54096

The library encodes all four of these facts โ€” read multiplier, write multipliers, base pricing, and minimum prefix โ€” so that neither the planner nor the cost calculator has to guess.

What a change actually invalidates

The prefix-match rule sounds absolute โ€” change one byte, lose everything after it โ€” and it is, but there is a useful nuance underneath it. The cache is layered in tiers that follow the render order, and a change only invalidates its own tier and the tiers that render after it. Changing a tool definition invalidates everything, because tools render first. Changing the system prompt invalidates the system and message caches but leaves the tools cache intact. Changing message content invalidates only the message cache. Toggling something like the thinking mode or the tool-choice hint sits in between.

The practical payoff is that you do not have to treat every request parameter as equally dangerous. You can flip tool_choice per request, or turn thinking on and off, without losing the expensive tools-and-system cache โ€” only tool definitions and the model itself force a full rebuild. That is worth knowing when you design an agent that changes behavior mid-run: encode the behavior change as message content or a cheap toggle, and the fat cached prefix survives.

What a mid-conversation change costs you

Reorder or edit a toolRebuilds everything โ€” tools render first, so the whole prefix is cold again
Edit the system promptRebuilds system and messages, but the tools cache survives
Append a new messageOnly the message cache moves; tools and system stay warm
Toggle tool_choice or thinkingCheap โ€” the tools and system cache are untouched

There is a corollary that catches teams running side computations. If a summarizer, a compaction pass, or a sub-agent spins up a separate request and rebuilds the system prompt, tools, or model with even a trivial difference, that fork misses the parent's cache entirely and pays cold write prices. The fix is mechanical: copy the parent's system, tools, and model verbatim into the fork, then append the fork-specific content at the end. planRequest makes this easy, because the stable inputs are explicit arguments you can pass through unchanged.

Module one: model facts

Start with the numbers, because everything else references them. The pricing table carries the input and output price per million tokens and the minimum cacheable prefix for each model, and the module exports the three multipliers as named constants so the cost math reads like the documentation rather than a pile of magic numbers.

// src/models.ts
export interface ModelPricing {
  inputPerMTok: number // USD per 1M input tokens
  outputPerMTok: number // USD per 1M output tokens
  minCacheableTokens: number // shorter prefixes silently will not cache
}

export const MODEL_PRICING: Record<string, ModelPricing> = {
  'claude-opus-4-8': {
    inputPerMTok: 5,
    outputPerMTok: 25,
    minCacheableTokens: 4096,
  },
  'claude-sonnet-5': {
    inputPerMTok: 3,
    outputPerMTok: 15,
    minCacheableTokens: 2048,
  },
  'claude-haiku-4-5': {
    inputPerMTok: 1,
    outputPerMTok: 5,
    minCacheableTokens: 4096,
  },
}

// Multipliers applied to the base input price.
export const CACHE_WRITE_MULTIPLIER_5M = 1.25
export const CACHE_WRITE_MULTIPLIER_1H = 2
export const CACHE_READ_MULTIPLIER = 0.1

export function getPricing(model: string): ModelPricing {
  const pricing = MODEL_PRICING[model]
  if (!pricing) {
    throw new Error(
      `Unknown model "${model}". Add it to MODEL_PRICING with its current pricing and minimum cacheable prefix.`
    )
  }
  return pricing
}

Two design notes worth internalizing. First, getPricing throws loudly on an unknown model rather than defaulting to some average, because a silent default would produce a savings report that is quietly wrong โ€” the worst kind of wrong for a number that ends up in a cost review. Second, keep this table as the single source of truth. The moment pricing lives in two places, one of them is stale.

Module two: planning the request

This is the heart of the library. planRequest takes the four inputs โ€” a stable system prompt, an optional deterministic tool list, an optional large context blob, and the volatile question โ€” and produces a request whose cache boundary is in exactly the right place: on the last stable block, and never on the question.

// src/cache-planner.ts (excerpt)
export function estimateTokensDefault(text: string): number {
  // A deliberately rough heuristic. Use count_tokens for real accounting;
  // this is enough to decide whether a prefix clears the minimum.
  return Math.ceil(text.length / 4)
}

export function planRequest(input: PlanInput): CachePlan {
  const estimate = input.estimateTokens ?? estimateTokensDefault
  const pricing = getPricing(input.model)

  // Stable blocks render in prefix order: tools, then system, then context.
  // The breakpoint goes on the LAST stable block so everything before it caches.
  const system: CacheableTextBlock[] = [{ type: 'text', text: input.system }]
  if (input.context) {
    system.push({ type: 'text', text: input.context })
  }
  const lastStable = system[system.length - 1]
  lastStable.cache_control =
    input.ttl === '1h'
      ? { type: 'ephemeral', ttl: '1h' }
      : { type: 'ephemeral' }

  // The question is volatile: it changes every request, so it must sit AFTER
  // the breakpoint with no cache_control of its own.
  const messages = [
    {
      role: 'user' as const,
      content: [{ type: 'text' as const, text: input.question }],
    },
  ]

  const tools = input.tools ?? []
  const stableText =
    stableStringify(tools) + input.system + (input.context ?? '')
  const estimatedStablePrefixTokens = estimate(stableText)

  const warnings: string[] = []
  if (estimatedStablePrefixTokens < pricing.minCacheableTokens) {
    warnings.push(
      `Stable prefix is about ${estimatedStablePrefixTokens} tokens, below the ` +
        `${pricing.minCacheableTokens}-token minimum for ${input.model}; it will not cache.`
    )
  }

  return {
    model: input.model,
    system,
    tools,
    messages,
    breakpoints: system.filter(b => b.cache_control).length,
    estimatedStablePrefixTokens,
    warnings,
  }
}

The subtlety here is entirely about placement. The default five-minute cache uses a bare ephemeral marker; the one-hour cache adds the ttl field. Either way the marker lands on the last stable block โ€” the context if there is one, otherwise the system prompt โ€” so that the tools and the system prompt fall inside the cached span. The question never gets a marker, because caching a value that changes every request would mean writing a fresh cache entry each time and reading none of them: pure write premium, zero payoff.

The token estimate exists for one reason: to warn you when your stable prefix is too small to cache before you ship it. Below the minimum, the provider gives you no signal at all, so the planner gives you one. The estimate is deliberately crude โ€” four characters to a token โ€” because it only has to answer a yes-or-no question about a threshold, not produce an invoice. For real accounting, the provider's token-counting endpoint is authoritative.

Where the boundary goes

Last stable block

Tools and the system prompt render before it and get cached; the volatile question renders after it and stays at full price. Put the boundary anywhere else and you either cache too little or bust the cache on every call.

Module three: auditing for silent invalidators

The planner puts the boundary in the right place, but a correctly-placed boundary still caches nothing if the bytes inside it change between requests. This is where most real-world caching quietly fails. The audit module exists to catch the culprits before they cost you.

There are two failure modes. The first is content that changes every request hiding inside the stable prefix: a Date.now() rendered into the system prompt header, a request UUID, a Math.random() seed, a wall-clock timestamp. Each one changes the prefix bytes on every call, so the cache is written fresh and never read.

// src/invalidator-audit.ts (excerpt)
const RULES: Array<{ rule: string; pattern: RegExp; hint: string }> = [
  {
    rule: 'iso-timestamp',
    pattern: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/g,
    hint: 'A timestamp in the prefix changes every request. Move it after the cache boundary.',
  },
  {
    rule: 'uuid',
    pattern: /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
    hint: 'A per-request id busts the cache. Keep ids out of the cached prefix.',
  },
  {
    rule: 'date-now',
    pattern: /Date\.now\(/g,
    hint: 'A rendered clock value differs every call. Inject time after the boundary.',
  },
  {
    rule: 'math-random',
    pattern: /Math\.random\(/g,
    hint: 'Randomness in the prefix guarantees a cache miss every request.',
  },
]

export function auditForInvalidators(text: string): Finding[] {
  const findings: Finding[] = []
  for (const { rule, pattern, hint } of RULES) {
    for (const match of text.matchAll(pattern)) {
      findings.push({ rule, match: match[0], index: match.index ?? 0, hint })
    }
  }
  return findings
}

The second failure mode is subtler: non-deterministic serialization. Tool definitions are objects, and JSON.stringify preserves whatever key order the object happened to be built with. Build your tool list from an object whose keys land in a different order on the next process boot โ€” a real risk when tools are assembled from a map or merged from config โ€” and the serialized bytes differ even though the tools are logically identical. The prefix hash changes; the cache misses. The fix is a deterministic serializer that sorts object keys recursively.

// src/invalidator-audit.ts (excerpt)
export function stableStringify(value: unknown): string {
  if (value === null || typeof value !== 'object') {
    return JSON.stringify(value)
  }
  if (Array.isArray(value)) {
    return '[' + value.map(stableStringify).join(',') + ']'
  }
  const keys = Object.keys(value as Record<string, unknown>).sort()
  const entries = keys.map(
    k =>
      JSON.stringify(k) +
      ':' +
      stableStringify((value as Record<string, unknown>)[k])
  )
  return '{' + entries.join(',') + '}'
}

Arrays keep their order โ€” order is meaningful for a tool list the model reads in sequence โ€” but object keys are sorted, so two logically identical tool sets always produce identical bytes. auditTools runs both halves together: it returns the canonical serialization and any invalidator findings inside it.

Silent invalidators, by how often they show up in real prompts (illustrative)

Timestamps and clocks in the prefix38.0%
Per-request ids and randomness27.0%
Non-deterministic JSON key order22.0%
Per-user data in the system prompt13.0%

Run this audit in a test against your real system prompt and tool list, and it becomes a regression guard: the day someone drops a timestamp into the preamble, a red test tells you before the cache read rate quietly craters in production.

Advertisement

Module four: measuring the savings

The whole point of caching is the money, and the money is knowable exactly, because every response comes back with a usage object that tells you precisely how the prompt tokens were billed. Three fields matter: input_tokens are the tokens billed at full price, cache_creation_input_tokens are the tokens written to the cache at the write multiplier, and cache_read_input_tokens are the tokens served from the cache at one tenth the price.

// src/savings.ts
export function computeSavings(
  model: string,
  usage: UsageLike,
  ttl: Ttl = '5m'
): SavingsReport {
  const pricing = getPricing(model)
  const inputPrice = pricing.inputPerMTok / 1_000_000
  const writeMult =
    ttl === '1h' ? CACHE_WRITE_MULTIPLIER_1H : CACHE_WRITE_MULTIPLIER_5M

  const uncached = usage.input_tokens
  const created = usage.cache_creation_input_tokens ?? 0
  const read = usage.cache_read_input_tokens ?? 0
  const totalPromptTokens = uncached + created + read

  const actualInputCostUsd =
    uncached * inputPrice +
    created * inputPrice * writeMult +
    read * inputPrice * CACHE_READ_MULTIPLIER
  const uncachedInputCostUsd = totalPromptTokens * inputPrice
  const savedUsd = uncachedInputCostUsd - actualInputCostUsd

  return {
    model,
    cacheHitRate: totalPromptTokens > 0 ? read / totalPromptTokens : 0,
    actualInputCostUsd,
    uncachedInputCostUsd,
    savedUsd,
    savedPct: uncachedInputCostUsd > 0 ? savedUsd / uncachedInputCostUsd : 0,
  }
}

The report compares two numbers: what you actually paid, and what you would have paid if every prompt token had been billed at full price. The difference is the savings, and the cache hit rate โ€” cached reads over total prompt tokens โ€” tells you how much of your prefix is actually being reused.

To see the shape of it, take a realistic coding assistant: a stable prefix of about 20,000 tokens (a system prompt, a tool catalog, and a retrieved knowledge base) and a fresh question of about 500 tokens on each call, running on Opus 4.8.

Prompt token distribution on a warm request (20k cached prefix, 500-token question)

Prompt token distribution on a warm request (20k cached prefix, 500-token question)
NameValue
Cache read at 0.1x20000
Uncached question at 1x500

On a warm request, 20,000 of the 20,500 prompt tokens are served from cache at a tenth of the price. That is a 97.6 percent cache hit rate and an input cost of about 1.25 cents, against the 10.25 cents you would have paid with no caching โ€” roughly an 88 percent reduction on the input side of that request.

Input cost per warm request

~88% lower

A 20k-token cached prefix served at one tenth the price drops a 10.25-cent request to about 1.25 cents, at a 97.6 percent cache hit rate. Output tokens are billed separately and are unaffected by caching.

The cold request tells the other half of the story, and it is the half cost models forget. On the very first call the 20,000-token prefix is written, not read, at the 1.25 write premium โ€” so that request costs about 12.75 cents, more than the 10.25 cents it would have cost with no caching at all. Caching is a bet that you will get enough warm reads to pay back that write.

Input cost per request in cents (20k-token prefix, Opus 4.8)

Input cost per request in cents (20k-token prefix, Opus 4.8)
scenariocents
Cold request (cache write)12.75
No caching10.25
Warm request (cache read)1.25

The bet pays off fast. Over a hundred requests โ€” one cold write followed by ninety-nine warm reads inside the cache window โ€” the cached path costs about 1.37 dollars against 10.25 dollars uncached, a roughly 87 percent reduction that swamps the one-time write premium after just a couple of reads.

Cumulative input cost in USD: uncached vs cached (1 cold write, then warm reads)

Cumulative input cost in USD: uncached vs cached (1 cold write, then warm reads)
requestsuncachedcached
10.10250.1275
101.0250.24
252.56250.4275
505.1250.74
10010.251.365

The break-even is the useful number to carry around. With the five-minute cache and its 1.25 write premium, you are ahead after the second request; with the one-hour cache and its heavier 2 write premium, you need a third request before the cache has paid for itself. That is the entire decision behind which cache lifetime to choose: pick the one-hour window only when your traffic has gaps longer than five minutes but you will still come back inside the hour.

Module five: wiring it together

The client is deliberately thin. It takes anything shaped like the Anthropic SDK โ€” an object with a messages.create method โ€” plans the request, sends it, and computes the savings from whatever usage comes back. Accepting the client by injection rather than constructing one internally is the single decision that makes the whole library testable offline.

// src/client.ts (excerpt)
export class CachingClient {
  constructor(private readonly opts: CachingClientOptions) {}

  async ask(input: Omit<PlanInput, 'model'> & { model?: string }) {
    const model = input.model ?? this.opts.model ?? 'claude-opus-4-8'
    const plan = planRequest({ ...input, model })

    const response = await this.opts.anthropic.messages.create({
      model,
      max_tokens: this.opts.maxTokens ?? 1024,
      system: plan.system,
      tools: plan.tools,
      messages: plan.messages,
    })

    const savings = computeSavings(model, response.usage, input.ttl)
    return { response, plan, savings }
  }
}

Because MessagesCreator is a structural interface โ€” just an object with messages.create โ€” a real @anthropic-ai/sdk client satisfies it in production, and a hand-written fake satisfies it in tests. Neither the planner nor the cost math ever touches the network, so the interesting logic is verifiable in milliseconds.

Testing the library

The test suite is where the design pays off, because the parts worth testing are pure functions. Four files cover the four modules, and none of them make a network call.

The planner tests assert the invariant directly: the cache marker lands on the last stable block, the question block carries no marker, the one-hour lifetime emits the ttl field, and a stable prefix below the model minimum produces a warning while a large one does not.

// tests/cache-planner.test.ts (excerpt)
it('places the cache boundary on the last stable block, never on the question', () => {
  const plan = planRequest({
    model: 'claude-opus-4-8',
    system: 'x'.repeat(80_000), // ~20k tokens, well over the minimum
    question: 'What changed in the auth module?',
  })

  const lastSystem = plan.system[plan.system.length - 1]
  expect(lastSystem.cache_control).toEqual({ type: 'ephemeral' })
  expect(plan.messages[0].content[0].cache_control).toBeUndefined()
  expect(plan.breakpoints).toBe(1)
  expect(plan.warnings).toHaveLength(0)
})

it('warns when the stable prefix is below the model minimum', () => {
  const plan = planRequest({
    model: 'claude-opus-4-8',
    system: 'too short to cache',
    question: 'hello',
  })
  expect(plan.warnings[0]).toMatch(/will not cache/)
})

The audit tests confirm that stableStringify produces identical bytes for two objects whose keys were built in different orders โ€” the property that actually protects the cache โ€” and that each invalidator rule fires on a positive case and stays quiet on a clean prompt.

// tests/invalidator-audit.test.ts (excerpt)
it('serializes identically regardless of key order', () => {
  const a = {
    name: 'search',
    description: 'find',
    input_schema: { type: 'object' },
  }
  const b = {
    input_schema: { type: 'object' },
    description: 'find',
    name: 'search',
  }
  expect(stableStringify(a)).toBe(stableStringify(b))
})

it('flags a Date.now call and a timestamp in the prefix', () => {
  const findings = auditForInvalidators(
    'Current time is Date.now() as of 2026-07-13T09:30'
  )
  const rules = findings.map(f => f.rule)
  expect(rules).toContain('date-now')
  expect(rules).toContain('iso-timestamp')
})

The savings tests pin the cost math to worked examples: a usage record that is mostly cache reads yields a high hit rate and a large savings percentage, an all-uncached record yields zero savings and a zero hit rate, and a one-hour lifetime charges strictly more on the write than a five-minute one for the same token counts. The client test uses a fake MessagesCreator that records the request body it was handed, then asserts the body carries cache_control on the last system block and that the returned report matches computeSavings on the canned usage. That is the full contract, verified without a key.

Running the demo, with or without a key

The entry point is written so npm start does something useful in both worlds. When ANTHROPIC_API_KEY is set, it constructs a real client, sends the same large stable context twice with two different questions, and prints the savings report for each โ€” so you watch the first call write the cache and the second read it, the hit rate jumping from near zero to the high nineties between the two.

When there is no key, the demo does not fail and it does not fake a network call. Instead it exercises the pure logic directly: it plans a request against a large context, prints any invalidator findings and planner warnings, and runs computeSavings against a hand-built usage record that mimics a warm read. That is enough to see the whole pipeline โ€” plan, audit, measure โ€” produce real output on a laptop with no credentials, which is exactly what you want when you are first evaluating whether caching is worth wiring into your own stack.

// src/index.ts (offline path, excerpt)
const plan = planRequest({
  model: 'claude-opus-4-8',
  system: KNOWLEDGE_BASE, // a large, stable context blob
  question: 'Summarize the retry policy.',
})
console.log('warnings:', plan.warnings)
console.log('audit:', auditForInvalidators(KNOWLEDGE_BASE))

// A simulated warm request: almost all tokens served from cache.
const savings = computeSavings('claude-opus-4-8', {
  input_tokens: 500,
  cache_creation_input_tokens: 0,
  cache_read_input_tokens: 20_000,
  output_tokens: 800,
})
console.log(`hit rate ${(savings.cacheHitRate * 100).toFixed(1)}%`)
console.log(
  `saved $${savings.savedUsd.toFixed(4)} (${(savings.savedPct * 100).toFixed(1)}%)`
)

Keeping the demo runnable offline is not a gimmick. It is the same property the tests rely on โ€” pure logic separated from I/O โ€” expressed as a thing you can run by hand. If the demo needed a key, so would every test, and the feedback loop would slow from milliseconds to seconds and from free to metered.

Patterns worth carrying beyond this project

The library is small, but the disciplines behind it are the ones that separate a cache that works from one that quietly does not.

Freeze the system prompt. The most common way to break caching is to interpolate live values โ€” the current date, the user's name, the active mode โ€” into the system prompt, which sits at the very front of the prefix and invalidates everything after it. Keep the system prompt byte-stable, and inject dynamic context later in the message list, where a change invalidates only the turns after it. On the newest models you can deliver trusted mid-session instructions as a system-role message inside the messages array rather than by editing the top-level system prompt, which keeps the cached prefix intact.

Serialize tools deterministically and never change them mid-conversation. Tools render at position zero, so adding, removing, or reordering a tool invalidates the entire cache. Sort your tool JSON, and if you need modes, encode the mode as message content rather than swapping the tool set.

Mind the lookback window. Each breakpoint walks backward only a limited number of content blocks to find a prior cache entry. In a long agentic loop that appends many tool-call and tool-result blocks in a single turn, a later breakpoint can fail to find the previous one and silently miss. Place an intermediate breakpoint every dozen-or-so blocks in long turns.

Pre-warm only when it pays. Firing a zero-output request at startup writes the cache so the first real request reads instead of writes, which is worth it when first-request latency is user-visible and there is a quiet moment before traffic. Under continuous traffic the first real request warms the cache for free, and a separate warm call is just an extra write.

Sequence your fan-out. A cache entry only becomes readable after the first response that wrote it begins streaming. Fire a hundred identical-prefix requests in parallel from a cold cache and all hundred pay the write premium โ€” none can read what the others are still writing. When you fan out over a shared prefix, send one request first, wait for its first streamed token, then release the rest; they read the cache the first one just warmed, and your bill reflects one write and ninety-nine reads instead of a hundred writes.

Do not cache what does not repeat. If the first thousand tokens of your prompt differ on every request, there is no reusable prefix, and adding a marker only pays the write premium with zero reads. Caching is for the fat, stable preamble โ€” measure first, and if the prefix is not shared, leave it off.

Step 1

Freeze and audit the prefix

Run auditForInvalidators against the real system prompt and tool list; make it a red test.

Step 2

Place one breakpoint

Mark the last stable block with planRequest; leave the volatile question uncached.

Step 3

Ship and measure

Read the usage fields into computeSavings on every response and watch the cache hit rate.

Step 4

Tune the lifetime

Choose the one-hour cache only when traffic gaps exceed five minutes but return inside the hour.

Where this fits

Prompt caching is one lever in a larger cost story. It attacks the repeated-input line item specifically; model routing attacks the wrong-model-for-the-job line item, and consumption-aware billing attacks the surprise-at-month-end problem that shows up when vendors move from seats to consumption pricing. The market pressure behind all of this is the same one our prediction that the agentic frontier token-pricing floor holds through Q4 2026 tracks: when the per-token floor stops falling, the only remaining lever is sending fewer tokens at full price, and caching is the cleanest way to do that. For the broader picture of why the industry is repricing token consumption at all, our news analysis on the hidden cost of AI token consumption pricing lays out the demand side.

The techniques here also compose cleanly with the request-validation discipline from the type-safe LLM tool-calling layer tutorial: the same deterministic tool serialization that protects your cache also gives you the stable schema your validator keys against. Caching and validation are two faces of the same idea โ€” treat the boundary between your code and the model as a place that deserves engineering, not guesswork.

Conclusion

Prompt caching is not exotic. It is a prefix match, a boundary you place on purpose, and a usage object that tells you exactly what you saved. The reason it so often fails to save anything is not the mechanism โ€” it is the invisible mistakes: a boundary in the wrong place, a timestamp in the prefix, a tool list that serializes differently each boot, and no measurement to catch any of it. The library you just built closes all four gaps: it places the boundary correctly, audits the prefix, serializes deterministically, and reports the savings in dollars, all verifiable offline.

Clone the complete project from the CrashBytes ByteSizedExamples repository, run the test suite without a key, and then point the client at your own system prompt and tool list. Watch the cache hit rate in the report climb toward the high nineties โ€” and watch the input line on your bill do the opposite.

Signed by Michael Eakins

PGP key fingerprint ends in 08E8 8F19 ยท signed 2026-07-13

Verify โ†’.sig
Advertisement

Was this article helpful?

Your feedback helps us improve our content and create more valuable resources

We appreciate honest feedback - it helps us serve you better

Work with us

This analysis is what we do for clients

CrashBytes consults on enterprise AI strategy and implementation, builds custom web and mobile software, and places senior engineers on corp-to-corp engagements.

See Services

Enjoyed this? Get the next one.

Join developers getting CrashBytes articles, tutorials, and predictions in their inbox. No spam, unsubscribe anytime.

Related Topics

TypeScriptAnthropicprompt cachingLLMcost optimizationtutorial
Back to Articles
โ† PreviousThe Duplex Turn: Full-Duplex Voice AI and the End of Taking TurnsNext โ†’The Allocation Turn: When Compute Is Rationed by Capacity, Not Price

From across the CrashBytes network

More than the blog โ€” predictions, news, fiction, and AI art.

PredictionCustom AI Chips Reach Commodity Status by Q4 2027: Cloud Provider Competition Drives Democratization
NewsWeek In Review July 19-25, 2026 - The Week The Money Moved To The Metering Layer
Short StoryThe Answer Key
AI ArtThe Room That Remembers

Continue Your Learning Journey

Explore more articles related to Tutorial and expand your knowledge.

๐Ÿ“„Tutorial

Parse Streaming JSON From an LLM: A Tolerant Partial-JSON Parser in TypeScript

A hands-on TypeScript tutorial that builds a tolerant partial-JSON parser so an LLM's streamed structured output renders field by field instead of stalling on the closing brace. Tokenizer, auto-closing parser, and 49 offline tests.

21 min readRead more
๐Ÿ“„Engineering

Build a Type-Safe LLM Tool-Calling Layer in TypeScript: Zod Validation and Auto-Repair

A hands-on TypeScript tutorial: validate LLM tool-call arguments with Zod, auto-repair malformed calls in a bounded loop, and dispatch fully-typed args to your handlers โ€” provider-agnostic, tested, and offline.

41 min readRead more
๐Ÿ“„Engineering

Build a Resilient Multi-Provider LLM Client in TypeScript: Timeouts, Retries, Circuit Breakers, and Failover

A hands-on TypeScript tutorial for building a production LLM client that survives slow upstreams, rate limits, transient 5xx errors, and whole-provider outages โ€” a per-attempt timeout, exponential backoff with jitter, a per-provider circuit breaker, and automatic failover, in about 250 lines of typed, fully tested code.

22 min readRead more
๐Ÿ“„Tutorial

Build a Pre-Deployment LLM Evaluation Pipeline in TypeScript

A hands-on TypeScript tutorial for a CI-integrated eval harness that gates LLM releases on capability, safety, and regression checks โ€” the discipline CAISI now requires from labs.

29 min readRead more