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.
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
Show text descriptionHide 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.
| Scenario | Uncached input | Cache writes | Cache reads | Output | Total |
|---|---|---|---|---|---|
| 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.
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: 655Explicit 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:
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:
| Cause | Fix |
|---|---|
| A timestamp, request ID, or user name interpolated into the system prompt | Move per-request data into the last user message |
Tool list built from a Set or object with unstable order, or tools added per request | Sort tools by name; keep the list fixed |
| JSON serialized with keys in varying order | Serialize deterministically |
| Editing or trimming early history | Only append; trim history in large, rare steps |
| Prefix shorter than the model's minimum | Expected: short prompts are not cached |
| More than 5 minutes between requests | Use 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:
// 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 };
}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.224Two 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.
Hover or tap a component to see what it does.
Show text descriptionHide 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.
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
- Put a system prompt of at least 1,000 tokens in
prompts/handbook.mdand runcached-chat.ts. Confirm that turns 2 and 3 show cache reads. - Add
Current time: ${new Date().toISOString()}to the start of the system prompt and run again. Note the reads drop to zero. - Move the timestamp into the user message instead, run again, and confirm the reads come back.
- Change the simulation to 30 turns and compare the savings with 10 turns.
Check your understanding
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_controlmoves 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.
Related articles
- LLM API Keys, Billing, and Security for Web DevelopersKeep LLM API keys server-side, understand token-based billing, compute the cost of every call, and enforce per-user budgets, timeouts, and retries in Node.js.
- Cost Engineering for LLM Apps: Unit Economics, Routing, and BudgetsModel and cut LLM costs in TypeScript: unit economics, a cost model for levers like caching and batching, model routing with escalation, and per-feature budgets.