CLAUDE LABJP
MCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructureEXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioningADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applicationsQUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the windowPRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days outFIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attributionMCP — Support for the 2026-07-28 spec is rolling out across Claude. The protocol moves from bidirectional and stateful to request/response, so MCP servers can now live on serverless and edge infrastructureEXTENSIONS — Three official extensions have landed: MCP Apps for server-rendered UI, Tasks for async and long-running work, and Enterprise Managed Auth for IdP-based org-wide provisioningADOPTION — MCP passed 400 million monthly SDK downloads, roughly 4x growth this year, settling into its role as the standard way to connect agents to applicationsQUOTA — Today, August 19, is the last day of the 50 percent weekly usage boost for Claude Code subscribers. If you have long agent runs queued, this is the windowPRICING — Claude Sonnet 5's introductory rate of $2 per million input tokens and $10 output ends August 31; standard pricing of $3 and $15 takes over on September 1, twelve days outFIX — A bug where MCP v2 connections endlessly reopened subscriptions against servers with fixed timeouts is resolved, and a forward_user_identity setting was added for user attribution
Articles/API & SDK
API & SDK/2026-05-12Advanced

Combining Haiku 4.5, Streaming, and Prompt Caching to Cut Costs in a Personal App — An Implementation Record

A hands-on record of combining Claude Haiku 4.5, streaming, and prompt caching to improve both cost and response speed in a personal iOS/Android app — including the mistakes made along the way.

Claude API117Haiku 4.5streaming22prompt cachingcost optimization13indie dev10low latency

About six months after integrating Claude API into my personal app, something felt off. The feature was working, but at the end of each month, the API bill was climbing faster than the user count.

I sat down with the request logs and traced the problem to three overlapping issues: the model was heavier than the task required; streaming was introducing instability on certain screens rather than improving the experience; and a nearly-static 800-token system prompt was being re-sent with every single request.

I tried to fix all three individually first, and failed at each attempt. Prompt caching in particular — I put the breakpoint in the wrong place and nearly concluded it "didn't work." It wasn't just failing to save money. It was actively costing more, because cache writes are billed at a premium.

This is the record of getting those three techniques to work together, mistakes included. I've also gone back and recalculated, from published pricing, something I originally judged by feel: which lever actually moved the bill, and by how much.


Why Three Techniques Were Needed Together

Before combining them, I tried each approach individually. That's where the real learning happened.

Haiku 4.5 alone delivered on the promise of being faster and cheaper than Sonnet. But switching models without updating prompts introduced inconsistency. Instructions that Sonnet followed reliably were occasionally ignored by Haiku — not randomly, but in a pattern I had to dig into.

Streaming alone improved the perceived responsiveness of the app. But it introduced new failure modes: dropped connections, partial response rendering, and edge cases in the UI that hadn't existed with the simple request-response model. Streaming looks simple on the surface but has hidden complexity in implementation.

Prompt caching alone didn't hit the theoretical savings I expected. The reason, as I later discovered, was a misunderstanding of where to place the cache breakpoint. Placing it incorrectly doesn't just fail to save money — it adds cache-write costs on top.

Combining all three required understanding how they interact. Here's the full sequence.


Environment and App Profile

The app is a personalized content app for iOS and Android, built and operated by me as an indie developer — which matters here, because it means there's no separate infrastructure budget to absorb a bad month. Users enter text, Claude generates a response, and the result is displayed in-app. The request profile looks like this:

  • System prompt: ~800 tokens (app-specific instructions, mostly static)
  • User input: 50–200 tokens (changes every request)
  • Expected output: 100–400 tokens

Backend: Node.js + TypeScript. Claude integration: the official Anthropic SDK. Monthly active requests: tens of thousands.

import Anthropic from "@anthropic-ai/sdk";
 
const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

Step 1: Migrating to Haiku 4.5 and Prompt Redesign

What Went Wrong with the Initial Migration

Haiku 4.5 occasionally failed to follow instructions that Sonnet handled without issue. After analyzing request logs, I identified the pattern: instructions placed in the second half of a long system prompt were being deprioritized.

The fix was to restructure the system prompt, moving the most critical instructions to the top and reducing the total density. I also found that Haiku responds better to instructions written in flowing sentences rather than bullet-point lists.

// Before: overpacked system prompt with mixed priorities
const systemPromptV1 = `
You are the assistant for [App Name].
- Respond to user input
- Always respond in Japanese
- Use a friendly tone
- Keep responses under 200 characters
- Avoid technical jargon
- Include 1-2 emoji
- Avoid negative statements
- Include encouraging content
... (continues)
`;
 
// After: critical instructions consolidated at the top
const systemPromptV2 = `
You are the assistant for [App Name].
Respond to user input in Japanese, within 200 characters, in an upbeat and encouraging tone.
 
Required:
- Always respond in Japanese
- Keep responses to 100-150 characters
- Include 1-2 emoji
- Maintain a positive tone
`;

Instruction compliance stabilized noticeably after this change.

Model Configuration

const response = await client.messages.create({
  model: "claude-haiku-4-5-20251001",
  max_tokens: 512,
  system: systemPromptV2,
  messages: [
    {
      role: "user",
      content: userInput,
    },
  ],
});

Step 2: Streaming Implementation and Its Hidden Issues

When Streaming Helps vs. When It Doesn't

Streaming improves experience when output is long and users are consciously waiting. For short output — under 100 characters — streaming can actually feel worse. Tokens appearing one by one look more natural when the output is substantial; for brief responses, it looks choppy.

Since most of my app's responses are under 200 characters, I added conditional routing:

type ContentRequest = {
  userInput: string;
  expectedLength: "short" | "long";
};
 
async function generateContent(req: ContentRequest): Promise<string> {
  if (req.expectedLength === "short") {
    return await generateWithoutStreaming(req.userInput);
  } else {
    return await generateWithStreaming(req.userInput);
  }
}

The Core Streaming Pattern

The most important part of streaming in a production mobile app isn't the happy path — it's handling disconnections and partial responses.

async function generateWithStreaming(userInput: string): Promise<string> {
  const chunks: string[] = [];
 
  try {
    const stream = await client.messages.stream({
      model: "claude-haiku-4-5-20251001",
      max_tokens: 1024,
      system: systemPromptV2,
      messages: [{ role: "user", content: userInput }],
    });
 
    for await (const chunk of stream) {
      if (
        chunk.type === "content_block_delta" &&
        chunk.delta.type === "text_delta"
      ) {
        chunks.push(chunk.delta.text);
        // Forward to UI via WebSocket/SSE here if needed
      }
    }
 
    const finalMessage = await stream.finalMessage();
 
    if (finalMessage.stop_reason !== "end_turn") {
      console.warn(
        `Unexpected stop_reason: ${finalMessage.stop_reason}`,
        { input: userInput }
      );
    }
 
    return chunks.join("");
  } catch (error) {
    if (error instanceof Anthropic.APIConnectionError) {
      console.error("Stream connection error:", error.message);
      throw error;
    }
    throw error;
  }
}

The stop_reason Problem I Didn't Expect

After deployment, logs showed stop_reason: "max_tokens" appearing sporadically. I had set max_tokens: 512, which should have been more than enough for 200-character Japanese responses.

The issue: Japanese characters can span multiple tokens. A "200-character" constraint in the system prompt doesn't map cleanly to a token budget. Responses of 400–500 characters were occasionally being generated, running into the token limit mid-output.

The fix was to reduce max_tokens to 256 and tighten the prompt instruction from "under 200 characters" to "approximately 100–150 characters." The buffer matters.


Step 3: Prompt Caching Design

Understanding the Caching Boundary Correctly

Three rules to internalize:

  1. A cache entry is created when a request includes cache_control: { type: "ephemeral" } for the first time
  2. The cache hits when all content before the cache breakpoint is identical in a subsequent request
  3. Cache entries expire after 5 minutes (ephemeral)

My initial mistake was placing the cache breakpoint after the user input — the part that changes every request. This meant the cache never matched.

// Wrong: breakpoint placed after user input (which changes every request)
const badRequest = {
  model: "claude-haiku-4-5-20251001",
  max_tokens: 256,
  system: [{ type: "text", text: systemPromptV2 }],
  messages: [
    {
      role: "user",
      content: [
        {
          type: "text",
          text: userInput,
          cache_control: { type: "ephemeral" }, // ← breakpoint after variable content
        },
      ],
    },
  ],
};
 
// Correct: breakpoint placed on the fixed system prompt
const goodRequest = {
  model: "claude-haiku-4-5-20251001",
  max_tokens: 256,
  system: [
    {
      type: "text",
      text: systemPromptV2, // ← this is static
      cache_control: { type: "ephemeral" }, // ← breakpoint on fixed content
    },
  ],
  messages: [
    {
      role: "user",
      content: userInput, // ← this changes each request, which is fine
    },
  ],
};

Monitoring Cache Hit Rate

The API response includes token breakdown fields: cache_creation_input_tokens and cache_read_input_tokens. Tracking these tells you whether the cache is actually working.

interface UsageStats {
  inputTokens: number;
  outputTokens: number;
  cacheCreationTokens: number;
  cacheReadTokens: number;
}
 
function extractUsage(response: Anthropic.Message): UsageStats {
  return {
    inputTokens: response.usage.input_tokens,
    outputTokens: response.usage.output_tokens,
    cacheCreationTokens: response.usage.cache_creation_input_tokens ?? 0,
    cacheReadTokens: response.usage.cache_read_input_tokens ?? 0,
  };
}
 
function calculateCacheHitRate(stats: UsageStats[]): number {
  const cacheHits = stats.filter((s) => s.cacheReadTokens > 0).length;
  return (cacheHits / stats.length) * 100;
}

My initial cache hit rate was 30–40%. The 5-minute TTL meant that during quiet periods, the cache expired before the next request arrived.

Keeping the Cache Warm

The solution was to proactively refresh the cache before it expired:

async function warmUpCache(): Promise<void> {
  try {
    await client.messages.create({
      model: "claude-haiku-4-5-20251001",
      max_tokens: 1, // minimal tokens to keep cost low
      system: [
        {
          type: "text",
          text: systemPromptV2,
          cache_control: { type: "ephemeral" },
        },
      ],
      messages: [{ role: "user", content: "ping" }],
    });
    console.log("Cache warmed up at:", new Date().toISOString());
  } catch (error) {
    // Warmup failure is non-critical
    console.warn("Cache warmup failed:", error);
  }
}
 
// Refresh every 4 minutes (before the 5-minute TTL expires)
setInterval(warmUpCache, 4 * 60 * 1000);

After adding this, cache hit rate stabilized at 70–80%.

The warmup calls themselves incur cache-write charges, though — something I wasn't tracking at the time. Whether this setup actually came out ahead is a question I revisit with numbers later in this article.


Step 4: The Combined Implementation

Unified Request Function

interface GenerateOptions {
  userInput: string;
  streaming: boolean;
  onStreamChunk?: (chunk: string) => void;
}
 
async function generateOptimized(
  options: GenerateOptions
): Promise<{ text: string; usage: UsageStats }> {
  const { userInput, streaming, onStreamChunk } = options;
 
  const systemConfig = [
    {
      type: "text" as const,
      text: systemPromptV2,
      cache_control: { type: "ephemeral" } as const,
    },
  ];
 
  if (streaming && onStreamChunk) {
    const chunks: string[] = [];
    const stream = await client.messages.stream({
      model: "claude-haiku-4-5-20251001",
      max_tokens: 256,
      system: systemConfig,
      messages: [{ role: "user", content: userInput }],
    });
 
    for await (const chunk of stream) {
      if (
        chunk.type === "content_block_delta" &&
        chunk.delta.type === "text_delta"
      ) {
        chunks.push(chunk.delta.text);
        onStreamChunk(chunk.delta.text);
      }
    }
 
    const finalMessage = await stream.finalMessage();
    return { text: chunks.join(""), usage: extractUsage(finalMessage) };
  } else {
    const response = await client.messages.create({
      model: "claude-haiku-4-5-20251001",
      max_tokens: 256,
      system: systemConfig,
      messages: [{ role: "user", content: userInput }],
    });
 
    const text =
      response.content[0].type === "text" ? response.content[0].text : "";
    return { text, usage: extractUsage(response) };
  }
}

Retry Handling

async function generateWithRetry(
  options: GenerateOptions,
  maxRetries = 3
): Promise<{ text: string; usage: UsageStats }> {
  let lastError: Error | null = null;
 
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await generateOptimized(options);
    } catch (error) {
      lastError = error as Error;
 
      if (error instanceof Anthropic.RateLimitError) {
        const waitMs = Math.pow(2, attempt) * 1000;
        console.warn(`Rate limit hit. Retrying in ${waitMs}ms...`);
        await new Promise((resolve) => setTimeout(resolve, waitMs));
        continue;
      }
 
      if (error instanceof Anthropic.APIConnectionError) {
        await new Promise((resolve) => setTimeout(resolve, 500));
        continue;
      }
 
      throw error;
    }
  }
 
  throw lastError ?? new Error("Max retries exceeded");
}

Before and After

Absolute figures depend heavily on your usage pattern. What follows holds the token profile from the top of the article constant and computes cost from published per-MTok pricing. It isn't my invoice — it's a way to size each lever against the others.

Assumptions: 800-token system prompt, 125-token user input, 250-token output, 30,000 requests per month.

ConfigurationInput / Output (per MTok)Monthlyvs. baseline
Before: Sonnet 3.5, no caching$3 / $15$195.75
Model swap only: Haiku 4.5, no caching$1 / $5$65.25-66.7%
After: Haiku 4.5, 77.5% cache hit rate$1 / $5$49.86-74.5%

The ordering is unambiguous. Two-thirds of the total reduction came from the model swap alone. Prompt caching stacks another 23.6% on top of that — a second-stage lever, not a first one. Streaming doesn't appear in this table at all, because it moves perceived latency rather than cost.

Building this table is where I realized I had worked the problem backwards. I started with caching, the smaller lever, and I had it configured wrong on top of that.

One dated note worth planning around: Sonnet 5's introductory $2/$10 pricing ends on September 1, 2026, moving to $3/$15. If you're on Sonnet 5, the input side of this same formula multiplies by exactly 1.5. The input-price gap against Haiku 4.5 widens from 2× to 3×, which makes the "does this task actually need Sonnet?" question worth more after September than before.


Calculate the Cache Break-Even Before You Enable It

Below a 21.7% hit rate, caching costs you money

Most explanations of prompt caching say "higher hit rate, bigger savings" and stop there. The boundary that hurt me in production was the one on the other side.

The pricing works like this: writing to the cache costs 1.25× the normal input rate, and reading from it costs 0.1×. Writes are expensive; reads are how you earn it back.

For a cached block with hit rate h, the per-request coefficient is 1.25(1 - h) + 0.1h. Break-even is where that equals 1.0 — normal, uncached transmission. Solving gives h = 0.25 / 1.15, about 0.217.

Computed against the same token profile:

Cache hit rateMonthlyvs. no caching ($65.25)
0% (writes only)$71.25+9.2%
10%$68.49+5.0%
21.7% (break-even)$65.26no change
50%$57.45-12.0%
75%$50.55-22.5%
90%$46.41-28.9%

At a 0% hit rate, enabling caching raises the bill by 9.2%. That isn't "marginal benefit." That's a loss.

And it's a realistic scenario. The ephemeral TTL is five minutes. During any window where requests arrive less often than that, you accumulate writes and never collect reads. Split your hit rate by hour and the daytime and overnight numbers will look like they came from different applications. A single blended average hides the hours sitting below the break-even line.

Running warmup unconditionally can cost more than it saves

I ran the four-minute warmup from earlier in this article through the same math.

Firing every four minutes for thirty days is 10,800 calls per month. Each one writes 800 tokens to the cache, putting warmup alone at $11.29 per month — a fixed cost that doesn't scale down with your traffic.

Requests / monthNo cachingUnconditional warmupDifference
5,000$10.88$18.56+70.7%
10,000$21.75$25.84+18.8%
20,000$43.50$40.39-7.2%
30,000$65.25$54.94-15.8%
60,000$130.50$98.59-24.5%

Unconditional warmup only beats no caching at roughly 15,700 requests per month — about 520 a day, or one every 2.8 minutes on average. Below that, adding warmup makes your bill larger.

The sharper comparison is against a cache that's already getting natural hits. At 30,000 requests a month, unconditional warmup lands at $54.94, while no warmup with a 77.5% natural hit rate lands at $49.86. Above a 59.1% natural hit rate at that volume, warmup is dragging you backwards.

The threshold by scale:

Requests / monthWarmup wins only if natural hit rate is below
10,000never worth it
20,00038.7%
30,00059.1%
50,00075.5%
100,00087.7%

So warmup isn't a technique that makes caching cheaper. It's a technique for filling in the quiet hours specifically. I replaced the unconditional setInterval with a check against time since the last real request.

let lastRequestAt = 0;
 
export function markRequest(): void {
  lastRequestAt = Date.now();
}
 
// Only warm the cache if no real request arrived in the last four minutes
async function warmUpIfIdle(): Promise<void> {
  const idleMs = Date.now() - lastRequestAt;
  if (idleMs < 4 * 60 * 1000) {
    return; // Natural hits are happening. Don't add a write on top.
  }
  await warmUpCache();
}
 
setInterval(warmUpIfIdle, 60 * 1000); // Check every minute, fire only when needed

What you need in order to decide is the distribution of your request intervals, not the average. Measure the percentage of requests that arrive within five minutes of the previous one. That number is approximately the ceiling on the hit rate you can reach without warmup at all.


Key Lessons

On Haiku 4.5: Front-load your critical instructions. If instruction compliance drops after switching from Sonnet, the issue is almost certainly prompt structure, not model capability.

On streaming: Don't apply it uniformly. Short outputs (<100 chars) are often better delivered all at once. Always log stop_reasonmax_tokens hits in production are a warning sign that your token budget and prompt constraints are misaligned.

On prompt caching: Cache breakpoints belong on fixed content. And if your traffic is intermittent, caching can cost more than it saves — the math put the floor for unconditional warmup at roughly 15,700 requests per month. Below that, not caching is the cheaper decision.


Closing Note

Looking back, more of my time went into deciding the order of these changes than into understanding any of them individually.

Whether Haiku 4.5 is the right model isn't a question the documentation can answer. You answer it by putting outputs side by side in your own app and counting how often the result falls below what you'd ship. I deferred that check and spent the time tuning cache settings instead — the lever that turned out to be three times smaller.

The break-even was the same kind of mistake. I enabled caching on the belief that a higher hit rate simply meant more savings, and it took me a long time to notice there were hours where no hits were happening at all. Knowing the number was 21.7% would have told me immediately what to measure first.

Streaming and prompt caching are tools. Neither is the point. They exist so that someone using your app feels the answer came back quickly and that it fits what they asked.

If you're staring at an API bill that feels too high, I'd suggest this order: confirm the model matches the task by comparing outputs, then measure your request-interval distribution, and only then touch caching. That sequence would have saved me about two weeks.

The first step is small — line up ten outputs and check whether your current model is heavier than the task needs. I'm still partway through this myself; combining batch processing with the rest, once rate limits allow more headroom, is next on my list.


Related Reading

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API & SDK2026-05-14
6 Traps I Hit Building In-App AI Chat with Claude API — A Record of Getting to Production
Six real design mistakes I encountered shipping Claude API in-app chat to production — covering context management, streaming error detection, guardrails, session persistence, model versioning, and cost monitoring. Includes working TypeScript code.
API & SDK2026-07-07
Four sites, one Claude bill: attributing spend per workspace to decide which pipeline to trim
When several projects share one Claude organization, the bill arrives as a single number that hides which pipeline is expensive. Field notes on splitting that spend per workspace with the Cost and Usage Report group_by, defensively parsing the results, and deciding what to trim by cost-effectiveness.
API & SDK2026-07-02
Introductory Pricing Has an End Date — Effective-Dated Cost Forecasts for the Sonnet 5 Price Step
Claude Sonnet 5's introductory $2/$10 pricing ends on 2026-08-31 and reverts to $3/$15. A static price map will quietly understate your September forecast by a third. Here is an effective-dated price table and forecast design that absorbs the step.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →