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./September 3, 2026/13 min

Your Playwright Timeouts Are Wrong for AI Features

Filed underai-testing/playwright/llm/flaky-tests
Your Playwright Timeouts Are Wrong for AI Features

Table of Contents
  • Why this matters now
  • Why do fixed timeouts fail on AI features specifically?
  • No single number fits a two-orders-of-magnitude spread
  • What’s actually wrong with just setting one big timeout?
  • How do you wait for an AI response without waitForTimeout?
  • How do you catch latency regressions instead of hiding them?
  • One ceiling absorbs cold starts; one assertion holds the SLO
  • Where should the timeout budget actually come from?
  • Your one next step

On this page

  • Why this matters now
  • Why do fixed timeouts fail on AI features specifically?
  • No single number fits a two-orders-of-magnitude spread
  • What’s actually wrong with just setting one big timeout?
  • How do you wait for an AI response without waitForTimeout?
  • How do you catch latency regressions instead of hiding them?
  • One ceiling absorbs cold starts; one assertion holds the SLO
  • Where should the timeout budget actually come from?
  • Your one next step

The AI-summary test that “passes” in 42 seconds is lying to you. A fixed timeout can’t tell the difference between the model is slow today and the feature is broken — it swallows both and shows you a green check. Every timeout heuristic you trust for a login form is wrong for an LLM feature, because the thing you’re waiting on doesn’t have a bounded, DOM-shaped completion event. It has a latency distribution that spans two orders of magnitude, and your single timeout number is trying to straddle all of it.

This is the failure mode nobody demos. The conference talk clicks “Summarize,” waits, asserts the text isn’t empty, and moves on. What it doesn’t show is that same test at 9 a.m. on a Monday when the provider is queuing, or right after a scale-to-zero cold start, or the week someone doubled the prompt and p95 quietly crept from 4 seconds to 19. The test stays green through all of it — until the day it flakes, and you “fix” it by bumping the timeout, which buries the regression deeper.

Why this matters now

Playwright shipped expect.poll and expect.toPass as first-class retrying assertions, and by v1.61 (June 2026) most enterprise suites have LLM features under test — chat, summarization, RAG answers, agent actions. Playwright’s auto-waiting is genuinely excellent for the web it was built for: it waits for elements to be attached, visible, and actionable before it acts. But generation-complete is not an actionability state. The DOM is “ready” the instant the streaming container mounts — long before the answer exists. So SDETs fall back to the one tool that always compiles: a hard-coded wait.

The stakes are that AI latency is not driven by your code, so you can’t reason about it the way you reason about a page load. P99 latency is usually driven by cold starts, provider load spikes, and queuing, not by your prompt. That means the variable you’re timing out on is mostly outside your test’s control — which is exactly why a single fixed number is the wrong instrument.

Why do fixed timeouts fail on AI features specifically?

Because an AI feature’s response time isn’t a value, it’s a distribution — and the spread between typical and worst-case is enormous. Queuing theory puts it plainly: P99 latency grows roughly as 1/(1-ρ), so at 80% utilization tail latency is 5x higher than at 20%. In real serving benchmarks that shows up as a P50 of 4.64 seconds against a P99 of 28 seconds on the same endpoint — a 6x gap that has nothing to do with whether your feature works. Add serverless inference and it gets worse: a scale-to-zero deployment can eat a 40-90 second penalty just to load the model and produce the first token, versus ~30ms per token once warm.

§ The distribution a single timeout is trying to straddle

5x

p99 vs p20 at load

Tail latency growth from queuing alone, per 1/(1-ρ)

6x

p50 → p99 gap

4.64s median vs 28s tail on the same endpoint under load

40-90s

Cold-start penalty

Serverless GPU model load before the first token streams

No single number fits a two-orders-of-magnitude spread

A login form resolves in a tight band — 200ms warm, maybe 2 seconds on a bad day. You can pick a 10-second ceiling and be right essentially always. An AI feature lives between 4 seconds and 90 seconds depending on cache state, queue depth, and whether you’re the request that triggered a scale-out. There is no single number that is both tight enough to catch a regression and loose enough to survive a cold start. Pick tight, you flake. Pick loose, you’re blind.

What’s actually wrong with just setting one big timeout?

It converts every latency regression and every half-broken state into a slow pass. Set the ceiling at 90 seconds so cold starts never flake, and you’ve also told the test to happily wait 89 seconds for a feature that used to answer in 4 — and to call it green. The timeout stopped being a correctness check the moment it got big enough to be reliable.

tests/ai-summary.spec.ts
await page.getByRole('button', { name: 'Summarize' }).click();
// The lie: a ceiling high enough to never flake is also high enough to hide everything
await page.waitForTimeout(60_000);
await expect(page.getByTestId('summary')).not.toBeEmpty();

That waitForTimeout is the async trap that makes suites both slow and flaky: it always waits the full duration on a fast run, and it still races on a slow one, because 60 seconds is a guess, not a signal. And not.toBeEmpty() will pass on a single streamed token, an error toast rendered as text, or a truncated answer. You’ve built a test that asserts “something appeared within a minute,” which is not a feature working.

[ WARNING ]

A timeout you raised until the flakiness stopped is not a passing test — it’s a silenced alarm. If your AI feature’s response time doubles, a generous fixed timeout reports that as success. You’ll find out from a user, not from CI. This is the same mechanism behind green tests that hide real bugs: the assertion is technically true and completely uninformative.

How do you wait for an AI response without waitForTimeout?

Wait on an explicit generation-complete signal that the application sets when the stream ends — not a fixed sleep, and not the text “looking done.” The unfakeable part here is that you have to make completion observable. I’ve watched teams poll for the answer text to “stop changing” for 500ms and call that done; it fails the first time the model pauses mid-stream on a slow token, and it fires early on a two-word answer. Heuristics on rendered text are guessing at a state the app already knows. Have the app expose it — a data-gen-state attribute, a completion event, a done flag in a status region — and poll that.

tests/ai-summary.spec.ts
await page.getByRole('button', { name: 'Summarize' }).click();
// The app flips data-gen-state to "complete" when the stream terminates.
// Poll the signal: generous ceiling for cold starts, cheap early exit when warm.
await expect
.poll(() => page.getByTestId('summary').getAttribute('data-gen-state'), {
intervals: [250, 500, 1000, 2000],
timeout: 90_000, // covers a serverless cold start — a ceiling, not the SLO
})
.toBe('complete');

expect.poll retries your function on a backoff schedule — the default intervals are [100, 250, 500, 1000] — until the matcher passes or the timeout hits. On a warm run this returns in a few hundred milliseconds; on a cold start it patiently waits without you sleeping the full duration on every green run. Reach for expect.poll when one value changes over time (a status attribute); reach for expect.toPass when several reads-and-asserts must all succeed in one attempt. One footgun worth knowing: toPass defaults to the test timeout unless you set expect.toPass.timeout, so it’s easy to accidentally inherit a giant ceiling and recreate the exact problem you’re trying to solve.

Make completion observable

Have the app set an explicit signal when generation terminates — data-gen-state="complete", a done event, an ARIA status region. Never infer “done” from rendered text stabilizing.

Poll the signal, not a clock

Use expect.poll against that attribute with a backoff interval array. The test exits the instant the signal flips, so fast runs stay fast.

Set the ceiling from p99, not a guess

The timeout is a cold-start safety net. Size it from your production p99 plus margin — high enough that it never flakes, because catching slowness is a different assertion’s job.

How do you catch latency regressions instead of hiding them?

Split the two questions the fixed timeout was conflating: did the feature respond at all (liveness) and did it respond within SLO (performance). The liveness wait is generous so it never flakes on a cold start. The latency check is tight, measured, and fails loud when the feature gets slow — and it runs only after liveness passes, so it never races.

tests/ai-summary.spec.ts
const started = Date.now();
await page.getByRole('button', { name: 'Summarize' }).click();
await expect
.poll(() => page.getByTestId('summary').getAttribute('data-gen-state'),
{ timeout: 90_000 })
.toBe('complete');
// Liveness passed. Now hold the feature to its SLO — as a separate assertion.
const elapsedMs = Date.now() - started;
expect(elapsedMs, `summary latency regressed: ${elapsedMs}ms`)
.toBeLessThan(SUMMARY_SLO_MS); // your production p95, not the 90s ceiling

One ceiling absorbs cold starts; one assertion holds the SLO

Now the 90-second ceiling does one job — absorb cold starts so the suite doesn’t flake — and SUMMARY_SLO_MS does the other: fail the build when a prompt change pushes p95 from 4 seconds to 19. One number can’t do both, which is the whole reason a single waitForTimeout was never going to work. If you want the earlier, sharper signal, assert on time-to-first-token as its own gate — TTFT catches provider-side slowness before the full generation even finishes, and it’s the metric users actually feel first.

“

A generous liveness timeout and a tight latency assertion are two different tools. Collapse them into one fixed timeout and you get a test that neither catches regressions nor survives cold starts.

Where should the timeout budget actually come from?

From your measured production percentiles, never from a pricing page or a round number that “felt safe.” Track p50, p95, and p99 segmented by model, prompt version, and input-token bucket, because the tails are where users suffer and an average hides every spike that matters. Set the liveness ceiling above your observed p99 with margin — it exists to not flake. Set the SLO assertion at the p95 you’re actually willing to ship. If you don’t have production numbers yet, run the latency assertion in report-only mode for two weeks, log the elapsed time per run, and derive both thresholds from the data instead of a vibe.

[ TIP ]

Bucket by input size before you set the SLO. A summary of a 500-word note and a summary of a 40-page contract are the same feature with wildly different latency profiles. One SLO across both either throttles the small case or excuses the large one — split the assertion by token bucket the same way you’d segment a streaming response race by payload.

Your one next step

Pick your single most-tested AI feature and do one thing this week: ask the developers to expose a data-gen-state (or equivalent completion signal), then replace the waitForTimeout in that spec with an expect.poll on the signal plus a separate expect(elapsedMs).toBeLessThan(...) in report-only mode. Don’t set the SLO threshold yet — just log the elapsed time for two weeks. You’ll learn two things you don’t currently know: what that feature’s real latency distribution looks like in CI, and how often your “passing” test was actually waiting 40 seconds for a cold start it never told you about. Set the p95 threshold from that data, flip enforcement on, and you’ve turned a silenced alarm back into a real one.

§ Frequently Asked FAQ
+ Doesn't Playwright's auto-waiting handle this already?

No. Auto-waiting waits for elements to be attached, visible, and actionable — states of the DOM. Generation-complete is not a DOM state; the streaming container is “ready” the moment it mounts, long before the answer exists. You need to wait on an explicit completion signal the application sets, using expect.poll, not rely on actionability checks.

+ Why not just poll until the answer text stops changing?

Because text-stability heuristics guess at a state the app already knows. They fire early on short answers and fail when the model pauses mid-stream on a slow token. Have the application expose an explicit signal — a data attribute, done event, or status region — and poll that instead of inferring completion from rendered text.

+ What timeout value should I use for an LLM feature?

Two different numbers for two different jobs. The liveness ceiling on expect.poll should sit above your production p99 with margin so it never flakes on a cold start. The SLO assertion — a separate expect on elapsed time — should sit at the p95 you’re willing to ship. Derive both from measured production percentiles, not a round number.

+ expect.poll or expect.toPass for AI responses?

Use expect.poll when a single value changes over time, like a data-gen-state attribute flipping to complete — that’s the common AI case. Use expect.toPass when several reads and assertions must all succeed within one attempt. Note that toPass defaults to the test timeout unless you set expect.toPass.timeout, so it’s easy to inherit a giant ceiling by accident.

§ Further Reading 03 of 03
01AI Testing

Temperature 0 Isn't Deterministic — Your AI Test Is Flaky

Temperature 0 gives you greedy decoding, not reproducibility. Batch size on the server flips tokens — here's what your Playwright tests should assert instead.

Read →
02AI Testing

Your Agent's Confirm Dialog Is Safety Theater

Your Playwright test checks the confirm dialog renders — not that the irreversible action was blocked. Test the human-in-the-loop approval gate properly.

Read →
03AI Testing

Your Agent Has More Permissions Than Your User

AI agents call tools with service credentials, so user authorization vanishes at the tool boundary. Test the confused deputy in Playwright before an auditor.

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.