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/14 min

Gate Time-to-First-Token, Not Total Latency

Filed underai-testing/playwright/llm/performance-testing/cicd
Gate Time-to-First-Token, Not Total Latency

Table of Contents
  • Why does total latency hide a bad streaming experience?
  • What exactly should you measure — TTFT, ITL, and total?
  • How do you measure time-to-first-token in Playwright?
  • Perceived TTFT: a stopwatch to the first character
  • Transport TTFT: timestamp every chunk at the reader
  • Why measure p95 and not the average?
  • Where does a TTFT gate belong — the PR pipeline or nightly?
  • Your one next step

On this page

  • Why does total latency hide a bad streaming experience?
  • What exactly should you measure — TTFT, ITL, and total?
  • How do you measure time-to-first-token in Playwright?
  • Perceived TTFT: a stopwatch to the first character
  • Transport TTFT: timestamp every chunk at the reader
  • Why measure p95 and not the average?
  • Where does a TTFT gate belong — the PR pipeline or nightly?
  • Your one next step

Your AI chat feature has a latency SLO — p50 total response under two seconds — and it’s green. Meanwhile a user clicks “Send,” watches a blank box for a second and a half, and decides the thing is broken. Both are true at once, and the reason is that you’re measuring the wrong number. On a streaming feature, the latency that decides whether the experience feels alive is time-to-first-token, not total time. Your suite almost certainly gates on the one users don’t feel.

I watched this cost a team a full sprint. A dev added a synchronous auth-token refresh right before the stream opened. Total latency barely moved — the refresh took 300ms and the model still finished in about the same wall-clock time — so the SLO stayed green. But TTFT p95 went from ~700ms to ~2.4s, and the “explain this” panel went from feeling instant to feeling frozen. Support tickets, not a red build, is how they found out. This is the gap between the metric you track and the metric users live in, and it’s exactly the kind of metric that lies while pointing the right direction.

Why does total latency hide a bad streaming experience?

Because streaming decouples the first token from the last one, and users judge responsiveness on the first. A non-streamed request has one moment that matters: it’s done. A streamed one has two — when it starts answering and when it finishes — and human perception gates almost entirely on the first. Nielsen Norman Group’s response-time limits have held for decades: 0.1s feels instant, 1s keeps the user’s flow of thought intact, and past 10s you’ve lost their attention. A stream that paints its first token at 400ms and finishes at 3s sits inside the flow limit the whole way. A stream with the same 3s total but a 1.8s TTFT breaks flow before a single word appears.

[ WARNING ]

A green p50-total SLO on a streaming feature is a measurement that averages away the moment users care about. Two responses can share an identical total latency while one feels instant and the other feels frozen — the difference is entirely in the first token, which total latency never sees.

This isn’t a rounding error. Perceived performance gates on time-to-first-meaningful-pixel, not time-to-complete — a first token at 180ms reads as faster than a whole answer delivered at 600ms. And the stakes compound outside the lab: Amazon’s classic finding was that every 100ms of added latency cost 1% in sales, and the same abandonment curve applies the second your AI feature makes someone wait on a blank panel.

What exactly should you measure — TTFT, ITL, and total?

Three numbers, and they fail in different ways. Measuring only the last one is how the blank-box regression ships green.

  • TTFT (time-to-first-token) — from the send action to the first rendered character. This is the “did it wake up” metric and the one users feel most. Anyscale’s serving metrics guide defines it as request-start to first output token; on the client, measure it to first visible token.
  • ITL (inter-token latency) — the gap between consecutive tokens. Low TTFT with high ITL is a stream that starts fast and then stutters — the jank users describe as “it keeps freezing.” Total latency hides this completely.
  • Total — send to last token. Still worth tracking, but as a ceiling, not the headline.
§ The three latencies of a streaming answer

TTFT

First token

What 'did it respond' feels like

ITL

Between tokens

Where stutter and jank live

Total

Last token

A ceiling, not the headline

For calibration on what “good” looks like in 2026: current inference benchmarks put Groq’s Llama 4 405B at roughly a 0.18s TTFT p50 at 480 tokens/sec — but that’s raw serving latency. By the time you add network egress (US-East to APAC alone adds 180–220ms p50), your gateway, and frontend hydration, the number your user experiences is several times that. And if the feature routes to a reasoning model, TTFT carries a 5–30× tax while the model thinks before it speaks — which is precisely why a chat UX cannot silently fall back to reasoning mode without a gate catching it.

How do you measure time-to-first-token in Playwright?

Two ways, and you want both: the perceived metric users feel, and the transport metric that tells you where a regression came from. Start with perceived, because it’s Playwright-native and it’s the one that maps to the SLO you actually care about.

Perceived TTFT: a stopwatch to the first character

The perceived measurement is just a stopwatch around “click Send” to “first non-empty character in the answer node.” The trick is to wait for the first character, not the finished response — the opposite of what you do when asserting on a completed streaming answer.

tests/ai-latency.spec.ts
test('first token arrives within the flow-limit budget', async ({ page }) => {
await page.goto('/support');
await page.getByRole('textbox', { name: 'Ask a question' }).fill('How do I get a refund?');
const start = Date.now();
await page.getByRole('button', { name: 'Send' }).click();
const answer = page.getByTestId('assistant-message');
// First character, NOT the finished answer — this is TTFT, not total.
await expect.poll(async () => (await answer.innerText()).trim().length)
.toBeGreaterThan(0);
const ttft = Date.now() - start;
expect(ttft, `TTFT ${ttft}ms exceeded the 1s flow limit`).toBeLessThan(1000);
});
[ TIP ]

expect.poll defaults to backing-off intervals (100ms, 250ms, 500ms…), which quantizes your TTFT to ~100ms buckets. That’s fine for a 1s budget, but if you’re chasing sub-100ms differences, tighten it with intervals: [30] or use the transport hook below. Measuring latency with a coarse sampler and then arguing about 40ms is a classic own-goal.

Transport TTFT: timestamp every chunk at the reader

The transport measurement hooks the streaming reader itself, so you get the true first-chunk timestamp and every inter-token gap. Inject it before navigation with addInitScript, wrap fetch, and tee the ReadableStream reader so you timestamp each chunk as it arrives — SSE is the transport under nearly every LLM stream, so each chunk is a real token boundary.

tests/fixtures/ttft-probe.ts
export const installTtftProbe = (page) => page.addInitScript(() => {
const probe = { ttft: -1, itl: [] as number[] };
(window as any).__ttft = probe;
const origFetch = window.fetch;
window.fetch = async (...args) => {
if (!String(args[0]).includes('/api/chat')) return origFetch(...args);
const start = performance.now();
const res = await origFetch(...args);
const reader = res.body!.getReader();
let last = start;
const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) return controller.close();
const now = performance.now();
if (probe.ttft < 0) probe.ttft = now - start; // first chunk
else probe.itl.push(now - last); // subsequent gaps
last = now;
controller.enqueue(value);
},
});
return new Response(stream, { headers: res.headers, status: res.status });
};
});

Now await page.evaluate(() => window.__ttft) hands you the transport TTFT and the full ITL array. When perceived TTFT regresses but transport TTFT is flat, the model is fine and your frontend got slower — the blocking-refresh bug from the intro shows up exactly here.

Why measure p95 and not the average?

Because the average is the number that lied to the team in the first place — a mean smears the slow requests into the fast ones and reports a lie users never experience. Tail latency is where streaming pain actually lives, and it amplifies with scale. Dean and Barroso’s The Tail at Scale is the canonical read: when a request fans out across backends, per-backend p99 hiccups combine into a much worse end-to-end tail, and they showed hedging at the 95th percentile cutting a BigTable lookup’s p99 from 1800ms to 74ms. Your AI gateway, retrieval layer, and model provider are exactly that kind of fan-out.

“

If your latency gate reports a mean, it’s optimized to pass on exactly the runs your users won’t remember and stay silent on the ones they will.

So gate on p95 TTFT over a batch of runs, not a single measurement and not the average. One green run tells you almost nothing about a distribution with a fat tail.

tests/ai-latency-p95.spec.ts
const percentile = (xs: number[], p: number) => {
const s = [...xs].sort((a, b) => a - b);
return s[Math.ceil((p / 100) * s.length) - 1];
};
test('TTFT p95 stays under budget', async ({ page }) => {
const samples: number[] = [];
for (let i = 0; i < 20; i++) samples.push(await measureTtft(page));
const p95 = percentile(samples, 95);
expect(p95, `TTFT p95 ${p95}ms over ${samples.length} runs`).toBeLessThan(1200);
});

Where does a TTFT gate belong — the PR pipeline or nightly?

Both, split by what each is allowed to measure. Conventional wisdom — which I mostly agree with — says keep performance tests out of the PR pipeline because they’re slow and flaky under shared CI load. A full load test absolutely stays out. But a single-user TTFT smoke gate is the exception that earns its place inside the pipeline, as long as you’re strict about what it’s measuring.

PR gate: frontend TTFT against a fixed-cadence mock

Point the gate at a mocked stream with deterministic, realistic timing. The backend latency is now a constant, so any TTFT regression the gate catches is your frontend — a blocking call before the stream opens, a hydration bundle that grew, a render that waits for the whole payload. This is fast, deterministic, and blocks the merge that would ship the blank box.

Nightly: real-model TTFT p95 against staging

Run the same probe against a live model on staging, 20+ iterations, gate on p95. This catches what the mock can’t — a provider slowdown, a region change, an accidental switch to a reasoning model, a retrieval step that got heavier. It’s allowed to be slower because nothing is waiting on it.

The split resolves the tension cleanly: the PR gate never touches a real model, so it’s not flaky and it’s not slow, but it still fails the merge that would have regressed the first token. The nightly job owns the noisy, real-world tail.

§ Delta ·

Before

→ ↓

After

[ TIP ]

Run the gate in report-only mode first — log per-run TTFT for two weeks without failing anything, then set the budget at your observed p95 plus a small margin. A budget pulled from a vendor benchmark instead of your own numbers either throttles healthy PRs or never fires. This is the same discipline that keeps an AI token-budget gate honest: calibrate from your data, not a pricing page.

One real gotcha before you ship this: reasoning models stream a token, go quiet for a beat while they think, then resume. That quiet period is a legitimate high ITL, not a stall — so alert on ITL tails on the nightly job where you know the real model, but keep the deterministic PR gate on TTFT, where the mock removes the ambiguity.

Your one next step

Pick your most-used AI feature and add a single perceived-TTFT probe to its existing spec — the stopwatch around click-to-first-character, nothing more. Run it 20 times locally and look at the spread, not the average. You’ll almost certainly find a p95 that’s 2–3× your p50, and that gap is the experience your total-latency SLO has been hiding. Once you’ve seen your own tail, wire the mocked-stream version into the PR gate in report-only mode and let it watch for a fortnight before you turn on enforcement.

[ NOTE ]

Want more of the synchronization and performance bugs that never make it into the official docs? Grab the weekly QA automation newsletter using the signup just below — no fluff, just patterns from 10+ years of shipping enterprise suites.

§ Frequently Asked FAQ
+ What's the difference between TTFT and total latency?

Time-to-first-token (TTFT) is the time from the send action to the first rendered character; total latency is the time to the last token. On a streaming feature, users judge responsiveness almost entirely on TTFT, because that’s when the answer visibly starts. Total latency can stay within budget while TTFT regresses badly — which is exactly how a frozen-box experience ships green.

+ How do you measure time-to-first-token in Playwright?

Two ways. For the perceived metric, put a stopwatch (Date.now()) around the send click and wait for the first non-empty character with expect.poll. For the transport metric, use addInitScript to wrap window.fetch and tee the ReadableStream reader so you timestamp the first SSE chunk and every inter-token gap, then read them with page.evaluate.

+ Should a latency gate run in the PR pipeline or nightly?

Both, split by scope. A single-user TTFT smoke gate against a fixed-cadence mocked stream belongs in the PR pipeline — deterministic, and it only catches frontend regressions. Real-model TTFT p95 against staging belongs nightly, where provider, region, and model-routing drift surface. Keep full load tests out of the PR pipeline entirely.

+ Why gate on p95 instead of average TTFT?

Because averages smear slow requests into fast ones and report a latency users never feel. Streaming pain lives in the tail, and tail latency amplifies across fan-out systems. Measure TTFT over 20+ runs and gate on the 95th percentile so the slow experiences can’t hide behind a healthy mean.

§ Further Reading 03 of 03
01AI 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 →
02AI Testing

Your AI Agent Retries and Charges the Card Twice

A tool call commits but the response is lost, so your AI agent retries and double-charges. Here's the Playwright test a senior SDET writes for exactly-once.

Read →
03AI Testing

Your 30-Case AI Eval Can't Tell If the Model Got Worse

You upgraded the model, reran your 30-case AI eval, and it passed. That comparison is statistically meaningless. Here's the paired-test math a senior SDET uses.

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.