Skip to content
AI Full-Stack Wiki

Prompt Caching and Batches: Cutting LLM API Costs in TypeScript

Cut LLM costs with prompt caching and the Batches API: how prefix caching works, where to put breakpoints, how to verify cache hits, and how to model costs.

Difficulty: Intermediate16 min readUpdated
On this page· How prompt caching works

Why this matters

LLM apps resend the same tokens constantly: the system prompt, tool definitions, retrieved documents, and the whole conversation so far, on every turn. Prompt caching lets the API reuse that work and bill it at a small fraction of the input price, and it also makes responses start faster. The Batches API halves the price of anything that can wait. Together they routinely cut a bill by 50 to 90 percent without changing a single answer.

How prompt caching works#

The API processes a request in a fixed order: tools, then system, then messages. When you mark a point in that sequence with cache_control, the API stores the processed prefix up to that point. A later request whose beginning is byte-for-byte identical up to that point reads it from the cache instead of processing it again.

Three consequences shape everything else:

  • It is a prefix match. Any change, even one character in the system prompt, invalidates the cache from that point onward. Put stable content first and changing content last.
  • Writes cost a little more, reads cost much less. On claude-opus-5-5, input is $4 per million tokens, a 5-minute cache write is $5 (1.25×), and a cache read is $0.20 (a 95% discount). Prices are list prices at the time of writing.
  • Entries expire. The default lifetime is 5 minutes, refreshed each time the entry is read. A 1-hour lifetime (ttl: "1h") costs 2× the input price to write and suits prompts reused less often than every few minutes.

A prompt must also be long enough to cache. The minimum is model-dependent: on Claude Opus 5.5 it is 512 tokens, and a shorter prefix is processed normally without caching (no error, just no discount).

What it saves#

  • Uncached input
  • Cache writes
  • Cache reads
  • Output
Prompt caching cost comparison for a 10-turn conversation with a 12,000-token system prompt and tools: caching the growing prefix cuts the total from $0.79 to about $0.22.
Show text description

A 10-turn chat on claude-opus-5-5 with a 12,100-token stable prefix (tools plus system prompt), about 700 new tokens per turn, and 400 output tokens per turn. List prices at the time of writing: $4 per million input tokens, $5 per million cache-write tokens, $0.20 per million cache-read tokens, $20 per million output tokens.

Conversation cost in US dollars
ScenarioUncached inputCache writesCache readsOutputTotal
Without caching$0.710$0.000$0.000$0.080$0.790
With prompt caching$0.000$0.114$0.031$0.080$0.225

The numbers come from the simulation below. The cache write on the first turn costs slightly more than a normal request, and from the second turn on, nearly all input is read from cache.

Caching a chat#

The simplest correct setup for a multi-turn chat is automatic caching: a top-level cache_control places the cache point on the last cacheable block of each request, so the cached prefix moves forward as the conversation grows. Each turn reads everything up to the previous turn and writes only the new part.

cached-chat.ts
import Anthropic from "@anthropic-ai/sdk";
import { readFileSync } from "node:fs";
 
const client = new Anthropic();
// A long, stable prompt: product docs, policies, examples. It must not change between requests.
const SYSTEM = readFileSync(new URL("./prompts/handbook.md", import.meta.url), "utf8");
 
const messages: Anthropic.MessageParam[] = [];
 
async function turn(userText: string) {
  messages.push({ role: "user", content: userText });
  const message = await client.messages.create({
    model: "claude-opus-5-5",
    max_tokens: 4096,
    system: SYSTEM,
    cache_control: { type: "ephemeral" }, // cache everything up to the last block
    messages,
  });
  messages.push({ role: "assistant", content: message.content });
 
  const u = message.usage;
  console.log(
    `in: ${u.input_tokens} uncached, ${u.cache_creation_input_tokens ?? 0} written, ${u.cache_read_input_tokens ?? 0} read | out: ${u.output_tokens}`,
  );
}
 
await turn("What is our refund policy for annual plans?");
await turn("And for monthly plans?");
await turn("Summarize both in a table.");

The output looks like this (illustrative numbers): the first turn writes the system prompt and first message to cache; later turns read almost everything and write only the latest exchange.

in: 9 uncached, 12804 written, 0 read | out: 412
in: 6 uncached, 431 written, 12804 read | out: 380
in: 5 uncached, 402 written, 13235 read | out: 655

Explicit breakpoints#

For more control, mark blocks yourself with cache_control (up to four breakpoints per request). The common layout is one breakpoint after the stable part and one at the end of the conversation:

explicit-breakpoints.ts
import Anthropic from "@anthropic-ai/sdk";
 
const client = new Anthropic();
const tools: Anthropic.Tool[] = [
  { name: "get_invoice", description: "Fetch an invoice by ID for the current customer.", input_schema: { type: "object", properties: { invoice_id: { type: "string" } }, required: ["invoice_id"] } },
];
 
const message = await client.messages.create({
  model: "claude-opus-5-5",
  max_tokens: 4096,
  tools, // rendered first, so it is part of every cached prefix
  system: [
    // Breakpoint 1: tools and instructions, shared by every user and every conversation.
    { type: "text", text: "You are the billing assistant for Acme…(long, stable instructions)…", cache_control: { type: "ephemeral", ttl: "1h" } },
  ],
  messages: [
    {
      role: "user",
      content: [
        // Breakpoint 2: this user's conversation so far.
        { type: "text", text: "Why did my invoice total change?", cache_control: { type: "ephemeral" } },
      ],
    },
  ],
});
console.log(message.usage);

The 1-hour entry for the shared instructions keeps them warm between users who arrive minutes apart. When you mix lifetimes, put longer ones before shorter ones.

What breaks the cache#

When cache_read_input_tokens stays at zero, something in the prefix changes between requests. The usual suspects:

CauseFix
A timestamp, request ID, or user name interpolated into the system promptMove per-request data into the last user message
Tool list built from a Set or object with unstable order, or tools added per requestSort tools by name; keep the list fixed
JSON serialized with keys in varying orderSerialize deterministically
Editing or trimming early historyOnly append; trim history in large, rare steps
Prefix shorter than the model's minimumExpected: short prompts are not cached
More than 5 minutes between requestsUse ttl: "1h", or accept the miss

Check usage on every response in development. A dashboard of cache read ratio per feature catches regressions in production.

Modeling costs before you ship#

A small simulation answers "what will this feature cost?" before you write the feature. It uses the token counts you expect and the cache behavior described above:

lib/simulate.ts
// USD per million tokens for claude-opus-5-5 at the time of writing.
export const PRICE = { input: 4, cacheWrite: 5, cacheRead: 0.2, output: 20 };
 
export interface ChatShape {
  turns: number;
  prefix: number; // tools + system prompt tokens
  userPerTurn: number;
  outputPerTurn: number;
}
 
export interface Cost {
  uncached: number;
  write: number;
  read: number;
  output: number;
  total: number;
}
 
const usd = (tokens: number, perMillion: number) => (tokens * perMillion) / 1_000_000;
 
export function conversationCost(s: ChatShape, cached: boolean): Cost {
  let uncached = 0, write = 0, read = 0, previous = 0;
  for (let turn = 1; turn <= s.turns; turn++) {
    // Each request: prefix + all earlier exchanges + the new user message.
    const input = s.prefix + (turn - 1) * (s.userPerTurn + s.outputPerTurn) + s.userPerTurn;
    if (!cached) uncached += input;
    else {
      read += previous; // everything cached by the previous turn
      write += input - previous; // the new part
      previous = input;
    }
  }
  const cost = {
    uncached: usd(uncached, PRICE.input),
    write: usd(write, PRICE.cacheWrite),
    read: usd(read, PRICE.cacheRead),
    output: usd(s.turns * s.outputPerTurn, PRICE.output),
  };
  return { ...cost, total: cost.uncached + cost.write + cost.read + cost.output };
}
simulate.ts
import { conversationCost } from "./lib/simulate.ts";
 
const shape = { turns: 10, prefix: 12_100, userPerTurn: 700, outputPerTurn: 400 };
for (const cached of [false, true]) {
  const c = conversationCost(shape, cached);
  const parts = Object.entries(c).map(([k, v]) => `${k} $${v.toFixed(3)}`);
  console.log(cached ? "with caching:   " : "without caching:", parts.join(", "));
}

Running it prints the numbers in the chart above:

without caching: uncached $0.710, write $0.000, read $0.000, output $0.080, total $0.790
with caching:    uncached $0.000, write $0.114, read $0.031, output $0.080, total $0.224

Two things stand out. Caching cut input cost by about 80 percent, and output is now over a third of the bill, so the next savings come from shorter answers (or a lower effort setting), not more caching. For exact counts on real requests, client.messages.countTokens() returns the input token count for a request without running it.

The Batches API: half price for work that can wait#

Many jobs do not need an answer in seconds: nightly classification, enriching a product catalog, running an eval suite. The Message Batches API takes up to 100,000 requests at once, processes them asynchronously (most batches finish within an hour, all within 24 hours), and bills them at 50% of standard prices. Caching discounts stack on top.

The Message Batches APIYour job submits up to 100,000 requests in one batch, the API processes them asynchronously at half price, your job polls until the batch has ended, then reads results keyed by custom_id.endedYour jobnightly scriptbatches.create≤ 100,000 requestsProcessingusually < 1 hourResultsby custom_id

Hover or tap a component to see what it does.

Message Batches API flow: submit many requests at once, let them process asynchronously at half price, poll for completion, and match results by custom_id.
Show text description

Batching is the simplest cost cut for work that can wait: classification backfills, document enrichment, and eval runs.

  • Your job: Collects work that does not need an immediate answer.
  • batches.create: Each request has your custom_id and normal Messages API params.
  • Processing: Runs asynchronously at 50% of standard prices; most batches finish within an hour, all within 24 hours.
  • Results: Results can arrive in any order; match them by custom_id, never by position.
batch-classify.ts
import Anthropic from "@anthropic-ai/sdk";
 
const client = new Anthropic();
 
const reviews = [
  { id: "r-101", text: "Arrived broken, and support never answered." },
  { id: "r-102", text: "Exactly as described. Fast shipping!" },
  { id: "r-103", text: "Fine, but the color is darker than in the photos." },
];
 
const batch = await client.messages.batches.create({
  requests: reviews.map((r) => ({
    custom_id: r.id, // how you match results; they can come back in any order
    params: {
      model: "claude-haiku-4-5",
      max_tokens: 16,
      system: "Classify the sentiment of the product review. Answer with one word: positive, negative, or mixed.",
      messages: [{ role: "user", content: r.text }],
    },
  })),
});
console.log("submitted", batch.id);
 
// Poll until done. In production, run this from a scheduled job instead of keeping a process alive.
let status = batch;
while (status.processing_status !== "ended") {
  await new Promise((r) => setTimeout(r, 60_000));
  status = await client.messages.batches.retrieve(batch.id);
  console.log(status.processing_status, status.request_counts);
}
 
for await (const item of await client.messages.batches.results(batch.id)) {
  if (item.result.type === "succeeded") {
    const text = item.result.message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
    console.log(item.custom_id, text.trim());
  } else {
    console.log(item.custom_id, "failed:", item.result.type); // errored, canceled, or expired: retry these
  }
}

Common mistakes#

  • Dynamic content at the top of the prompt. A date or user name in the system prompt makes every request a cache miss. Keep it at the end.
  • Assuming caching works without checking. Read cache_read_input_tokens; zero means a miss.
  • Caching tiny prompts. Below the model's minimum, nothing is cached. Caching matters for long, repeated prefixes.
  • Using the real-time API for backfills. Anything that can wait hours should go through batches at half price.
  • Matching batch results by position. Results come back in any order. Use custom_id.
  • Optimizing input while ignoring output. Output tokens cost 5× input here. After caching, answer length is often the biggest lever.

Try it

Measure your own cache hit rate

  1. Put a system prompt of at least 1,000 tokens in prompts/handbook.md and run cached-chat.ts. Confirm that turns 2 and 3 show cache reads.
  2. Add Current time: ${new Date().toISOString()} to the start of the system prompt and run again. Note the reads drop to zero.
  3. Move the timestamp into the user message instead, run again, and confirm the reads come back.
  4. Change the simulation to 30 turns and compare the savings with 10 turns.

Check your understanding

Why does adding the current time at the start of the system prompt hurt caching?
Which field tells you a request used the cache?
A nightly job classifies 50,000 support tickets. What is the cheapest correct approach?

Key takeaways

  • Caching is a prefix match over tools, system, then messages: stable content first, changing content last.
  • Cache reads cost a small fraction of input; writes cost a little more. Verify hits with usage.
  • For chats, top-level cache_control moves the cache point forward automatically each turn.
  • Model costs with a simulation before shipping, and use the Batches API for anything that can wait.

Next steps#

Finished this article? Track it to see your progress on the roadmap.