Halmurat T.
Halmurat T.

Senior SDET

Home Blog Books ask About

The Dispatch

Weekly QA notes from the trenches.

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

© 2026 Halmurat T.

Automation 28
  • Selenium
  • Playwright
  • Appium
  • Cypress
AI Testing 67
CI/CD 12
  • GitHub Actions
  • Slack Reporting
QA Strategy 10
Case Studies 8
Blog/AI Testing
AI TestingHalmurat T./August 4, 2026/13 min

Your Parallel AI Tests Are Racing the Prompt Cache

Filed underai-testing/playwright/llm/cicd/parallel-execution
Your Parallel AI Tests Are Racing the Prompt Cache

Table of Contents
  • Why do parallel AI tests miss the prompt cache?
  • The routing trap nobody tells you about
  • How do you make the prompt cache deterministic in CI?
  • Pre-warm once, then let the workers read
  • Assert on the cached-token count
  • Force the cold path instead of hoping for it
  • What this pattern doesn’t fix
  • Your one next step this week

On this page

  • Why do parallel AI tests miss the prompt cache?
  • The routing trap nobody tells you about
  • How do you make the prompt cache deterministic in CI?
  • Pre-warm once, then let the workers read
  • Assert on the cached-token count
  • Force the cold path instead of hoping for it
  • What this pattern doesn’t fix
  • Your one next step this week

Your AI feature’s Playwright suite passes on your laptop and flakes in CI, and every failure is a timeout waiting for the first token to render. You’ve already blamed the model, bumped the timeout, added a retry. None of it is the bug. The bug is that you turned on prompt caching to cut your inference bill, and prompt caching is a shared, time-decaying, concurrency-hostile piece of state that your test suite now depends on and does not control.

Here’s the whole thing in one line: a cache entry does not exist until the first response begins, so when eight Playwright workers fire the same prompt prefix at the same instant, all eight miss. The first request pays the cold-path latency. So do the other seven. Then, on your laptop, running one test at a time, every request after the first one hits a warm cache and returns in a fraction of the time — which is exactly why you tuned your timeouts against a reality CI will never reproduce.

[ NOTE ]

This assumes you’re hitting a real model in at least one CI lane. If your whole suite is stubbed, the cache never enters the picture — start with mocking LLM responses in Playwright CI, then come back when you add the live lane.

Why do parallel AI tests miss the prompt cache?

Because cache writes are not atomic with request dispatch. Anthropic’s documentation says it plainly: “a cache entry only becomes available after the first response begins. If you need cache hits for parallel requests, wait for the first response before sending subsequent requests.” That single sentence is a design constraint on your entire test architecture, and almost nobody reads it as one.

Play it out with a fullyParallel Playwright project and workers: 8. All eight workers boot, all eight navigate, all eight trigger a chat request carrying the same 6,000-token system prompt and tool definitions. Nothing is cached yet. Eight cold requests go out in the same 200ms window. Eight cache writes happen — at 1.25× the base input token price — and zero cache reads. Your suite just paid ten times what it should have (cache reads are billed at 10% of base input) and every one of those eight tests saw worst-case time-to-first-token.

§ Cold cache vs. warm cache on the same prompt

~85%

Latency cut on a hit

Anthropic's own figure for long prompts

10%

Cost of a cache read

vs. base input tokens

5 min

Default TTL

Refreshed free on every hit

A latency delta that large is not a rounding error you can absorb with a slightly bigger timeout. It’s the difference between a 400ms first token and a 2.5s first token on the same prompt with the same model. If your test asserts that a streaming skeleton appears within 1 second, that test is a coin flip whose bias is set by scheduling order — the definition of a flaky test. And flakiness is already the dominant tax on suites: the share of teams reporting flaky tests climbed from 10% in 2022 to 26% in 2025. Adding a shared cache to a parallel suite is volunteering to grow that number.

The routing trap nobody tells you about

It gets worse on OpenAI, and this is the detail that took me longest to find. OpenAI’s caching is automatic for prompts of 1,024 tokens or more, and requests are routed to a machine by a hash of the prompt’s initial prefix — the cache is per-machine, not global. You can influence that routing with prompt_cache_key. And there’s a throughput ceiling: roughly 15 requests per minute per prefix + prompt_cache_key combination before requests start spilling onto other machines, where the cache is cold.

Read that against a CI suite. Sixteen workers × several model calls per test is trivially north of 15 rpm on a single shared prefix. Your requests get scattered across inference machines, hit rate collapses to something between 0% and “depends on the day,” and your latency distribution grows a fat tail — the exact behavior that tail-optimized caching research exists to characterize. Meanwhile your dev loop, running one test at a time, sits comfortably under the ceiling and gives you a 100% hit rate and a beautiful, lying benchmark.

[ WARNING ]

The instinct here is to give every worker a unique prompt_cache_key to “isolate” them. Don’t. That’s borrowing the storage-state isolation mental model and applying it to a cache where sharing is the whole point. Unique keys guarantee that every worker misses, forever. You want one key, warmed once, before the workers start.

How do you make the prompt cache deterministic in CI?

You stop letting the cache be an emergent property of scheduling order and make it an explicit setup step. Three moves, in order.

Pre-warm the cache in globalSetup, before any worker starts

Anthropic supports a zero-output warm request: set max_tokens: 0 with a cache_control breakpoint on your static system prompt, and the API writes the cache entry and returns immediately with an empty content array. One request, one write, then every worker reads. On OpenAI, fire a single real request carrying the same prefix and prompt_cache_key and await it.

Assert on cache telemetry, not on wall-clock latency

Never write expect(elapsed).toBeLessThan(1500) against a shared cache. Assert on the numbers the provider hands you: cache_read_input_tokens (Anthropic) or usage.prompt_tokens_details.cached_tokens (OpenAI). Those are deterministic. Wall-clock is not.

Test the cold path on purpose, in its own serial project

The first user after every deploy gets a cache miss. That path deserves a test, and it will never run by accident once you pre-warm. Force a miss with a per-run nonce in the prefix and pin that project to one worker.

Pre-warm once, then let the workers read

tests/global-setup.ts
import Anthropic from '@anthropic-ai/sdk';
import { SYSTEM_PROMPT } from './fixtures/prompt';
/** Runs once, before any Playwright worker exists. */
export default async function globalSetup() {
const client = new Anthropic();
// max_tokens: 0 writes the cache entry and returns immediately —
// no output tokens billed, no response to throw away.
const warm = await client.messages.create({
model: 'claude-sonnet-5',
max_tokens: 0,
system: [{ type: 'text', text: SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
messages: [{ role: 'user', content: 'warmup' }],
});
if (warm.usage.cache_creation_input_tokens === 0) {
throw new Error('Cache write did not happen — prefix is under the model minimum.');
}
}

That guard on the last line matters more than it looks. The minimum cacheable prompt is model-dependent — 1,024 tokens on Sonnet 5 and Opus 4.8, 4,096 on Haiku 4.5 — and if you’re under it, nothing is cached and no error is returned. Your suite runs cold and silent, and the only evidence is a bill. Fail loudly in setup instead.

Assert on the cached-token count

Now the actual test asserts a fact instead of a stopwatch:

tests/ai/cache-hit.spec.ts
import { test, expect } from '@playwright/test';
test('summarize hits the warm system-prompt cache', async ({ page }) => {
const usage = page.waitForResponse((r) => r.url().includes('/api/summarize'));
await page.goto('/documents/quarterly-report');
await page.getByRole('button', { name: 'Summarize' }).click();
const body = await (await usage).json();
// Deterministic. Survives parallelism. Catches prompt refactors
// that silently drop the hit rate to zero and 10x the bill.
expect(body.usage.cache_read_input_tokens).toBeGreaterThan(1000);
expect(body.usage.cache_creation_input_tokens).toBe(0);
await expect(page.getByTestId('summary')).toBeVisible();
});

This is the assertion I’d add first if I could only add one. It’s a cost regression test, and it catches a whole class of bug that no functional test will ever see: someone appends a Generated at ${new Date()} line to the system prompt, or reorders the tool definitions, or flips tool_choice, and the prefix hash changes. Cache hit rate goes to zero. Every test still passes. Every response is still correct — Anthropic is explicit that caching has no effect on output token generation, the response is byte-for-byte what you’d get uncached. The only symptom is a 10× input bill that shows up three weeks later in the token budget you set for the feature.

[ TIP ]

My favorite unfakeable version of this bug: Anthropic’s own troubleshooting guide warns that “some languages (for example, Swift, Go) randomize key order during JSON conversion, breaking caches.” If your tool definitions are serialized from a Go map, your cache hit rate is nondeterministic per process. Good luck finding that from a dashboard.

Force the cold path instead of hoping for it

Separate the two behaviors into two projects. One warm, parallel, cheap. One cold, serial, deliberate.

playwright.config.ts
export default defineConfig({
globalSetup: './tests/global-setup.ts',
projects: [
{
name: 'ai-warm',
// Every worker reads the entry globalSetup wrote. Parallel is safe
// because nothing here writes to the cache.
fullyParallel: true,
testDir: './tests/ai/warm',
},
{
name: 'ai-cold',
// A nonce in the prefix guarantees a miss. Serial, because a cold
// stampede is exactly the thing we're testing one instance of.
workers: 1,
fullyParallel: false,
testDir: './tests/ai/cold',
},
],
});

The cold project is where your latency assertions live, and they’re honest there, because you know for certain the cache is cold. Assert your p95 cold TTFT budget in ai-cold and your cached-token counts in ai-warm. Neither test can lie to you about which world it’s in.

§ Delta · What an 8-worker AI lane actually does Flake → signal

Before

8 cold writes, 0 reads, timeout flake tuned on a warm laptop

→ ↓

After

1 warm write in setup, 8 reads, latency asserted only in the cold lane

What this pattern doesn’t fix

Pre-warming makes the cache deterministic; it doesn’t make it free, and it doesn’t extend the TTL. The default 5-minute window is refreshed on every hit, so a busy suite keeps it alive on its own — but a slow lane, a queued runner, or a long beforeAll can easily leave a 5-minute gap, and then your “warm” project quietly runs cold. If your suite has gaps that long, pay for the 1-hour TTL for the CI lane or re-warm between projects.

And this is orthogonal to model behavior. A warm cache changes your latency and your bill, not your output — which means it cannot save you from a model that got worse. That’s a different gate entirely: see the real-model drift eval. Rate limits are also a separate failure mode with a separate fix, though they show up in the same 8-worker lane; I covered that in handling 429s in parallel AI tests.

Your one next step this week

Open the single AI test you trust least — the timeout-flaky one — and add one line: expect(body.usage.cache_read_input_tokens).toBeGreaterThan(0). Run it with your normal worker count. If it fails, you just proved your suite has been paying cold-path prices and cold-path latency on every run, and you now know exactly why that test flakes. Then add the globalSetup warm request and watch both the flake and the bill drop in the same commit.

[ NOTE ]

Get weekly QA automation insights — no fluff, just battle-tested strategies from 10+ years in the trenches.

§ Frequently Asked FAQ
+ Why do parallel AI tests miss the prompt cache?

Because a cache entry only becomes available after the first response begins. When several Playwright workers dispatch the same prompt prefix simultaneously, none of them can read an entry that no request has finished writing yet — so every worker pays cold-path latency and a cache-write charge. Warm the cache once in globalSetup, before any worker starts.

+ Should each Playwright worker get its own `prompt_cache_key`?

No. Unique keys per worker guarantee a miss on every worker. Use one shared prompt_cache_key for the suite and warm it once. Isolation is the right instinct for cookies and storage state; it’s the wrong instinct for a prefix cache, where sharing is the entire point.

+ Can I assert on response latency in an AI test?

Only in a lane where you control the cache state. Hits can cut time-to-first-token by up to 85% on long prompts, so the same assertion passes warm and fails cold. Latency assertions belong in a serial cold project that forces a miss with a per-run nonce; the warm parallel project asserts on cached-token counts.

+ How do I know whether my prompt was actually cached?

Check usage on the response. If cache_read_input_tokens and cache_creation_input_tokens are both 0, nothing was cached — usually because the prefix fell under the model’s minimum (1,024 tokens on Sonnet 5 and Opus 4.8, 4,096 on Haiku 4.5). No error is raised, so assert on it in globalSetup or you’ll never find out.

+ What silently breaks a prompt cache in a way tests won't catch?

Any change to the cached prefix — a timestamp in the system prompt, reordered tool definitions, a flipped tool_choice, an added image. Hits require a 100% identical prefix. The output stays correct, so every functional test passes; the only symptom is a 10× input bill. A cached-token assertion turns that invoice into a failing build.

§ Further Reading 03 of 03
01AI Testing

Your Parallel AI Tests Fail on 429s, Not the Model

Your Playwright AI suite flakes in CI but not locally. It's not the model — it's parallel workers slamming the provider's TPM limit into 429s. Here's the fix.

Read →
02AI Testing

Your Semantic Cache Test Passes. The Cache Still Leaks.

Your semantic cache serves a near-miss query the wrong user's answer and the DOM test stays green. How to test cache false hits and leaks in Playwright.

Read →
03AI Testing

Your AI Agent Is Looping, and No Test Caught It

A max-iterations cap hides livelock instead of catching it. Here's the Playwright test a senior SDET writes to detect cyclic tool calls before they hit prod.

Read →

Don't miss a thing

Subscribe to get updates straight to your inbox.

HT

No spam · Unsubscribe anytime

Welcome aboard!

You're on the list. Expect real-world QA insights — no fluff, no spam.

§ Colophon

Halmurat T. — Senior SDET writing about test automation, CI/CD, and QA strategy from 10+ years in the enterprise trenches.

Set in
IBM Plex Sans, Lora, and IBM Plex Mono.
Built with
Astro, MDX, Tailwind CSS & Expressive Code. Served by Vercel.
Privacy
No cookies. No tracking scripts on the main thread — analytics run sandboxed via Partytown.
Source
github.com/Halmurat-Uyghur
Terminal
Try /ask to query Halmurat's notes in a shell prompt.

© 2026 Halmurat T. · Written in plain text, shipped in plain time.

Search
Esc

Search is not available in dev mode.

Run npm run build then npm run preview:local to test search locally.