Tech

Core Web Vitals for AI Chat Interfaces: LCP, INP and CLS in Streaming UIs

Suggested path: Performance Engineering · View path →

A model that answers in 200ms can still feel slow if the interface around it fights the browser.

Key takeaways

  • Chat interfaces break the usual Core Web Vitals playbook because the page has no real content until the model starts responding — LCP has to be optimised around the interface shell, not the eventual answer.
  • Token-by-token streaming is a main-thread problem before it's a network problem: appending to the DOM on every token is what actually causes poor INP and layout shift, not model latency.
  • Reserving space for the response container before the first token arrives removes most chat-related CLS in a single change.
  • A fast model with a low time-to-first-token can still feel slow if the surrounding interface fights the browser's rendering pipeline — the two are measured differently and fixed differently.

Why chat interfaces break the usual playbook

Core Web Vitals were designed around a page that loads once and mostly sits still: LCP marks when the main content painted, CLS tracks unexpected movement after that, and INP measures how fast the page responds to input. A chat interface violates the assumption underneath all three — the "main content" doesn't exist yet when the page loads, and it keeps growing, token by token, for the entire interaction. See the general Core Web Vitals checklist for the baseline these interfaces still have to clear before the AI-specific problems even start.

Time to first token is a model-latency metric. LCP, INP and CLS are browser-rendering metrics. A 200ms time-to-first-token and a broken INP score can coexist in the same interface — they measure completely different things.

LCP: painting a page that has no content yet

On first load, the largest visible element in a chat UI is usually the empty message container, an input box, or a welcome state — not the answer, which doesn't exist until the user asks something. LCP for a chat app should be optimised like any other app shell: fast first paint of the input and layout chrome, not the eventual AI response. Where teams go wrong is rendering that shell client-side behind a JavaScript bundle, which delays the one paint that LCP actually measures on a fresh visit.

  • Server-render or statically render the empty shell — input box, header, suggested-prompt chips — so it paints before any client JavaScript has to run.
  • Don't block that first paint on auth checks or model-availability pings; show the shell, then progressively enable it.
  • If the interface reopens an existing conversation, treat the last message's render as the LCP-critical path, not the empty state.

INP: the input lags while tokens are still arriving

This is where most AI chat interfaces actually fail Core Web Vitals, and it has almost nothing to do with the model itself. A naive implementation appends each incoming token straight to the DOM as it arrives over the stream, often re-running markdown parsing or syntax highlighting on every single token. At even a moderate token rate, that's dozens of forced layout and paint cycles per second competing with the user's own clicks and keystrokes for the main thread — exactly what INP measures.

Rendering approachMain-thread costINP risk
Append and re-render on every tokenOne layout/paint pass per tokenHigh — input lags during streaming
Batch tokens with requestAnimationFrameOne pass per frame, ~60/sec maxLow — bounded by the frame budget
Buffer and flush every N tokens or M msConfigurable, lowest costLowest — trades a little visual smoothness for headroom

Watch out: re-parsing the entire growing message as markdown on every token is a common hidden cost. Parse incrementally, or only re-parse the last block that actually changed, instead of the whole message from scratch each time.

Batch DOM updates inside requestAnimationFrame and let a small buffer coalesce multiple tokens into a single paint. Users can't perceive the difference between per-token and per-frame updates, but the browser's main thread can.

CLS: the layout-shift trap of growing messages

A response container with no reserved height grows as tokens stream in, pushing the input box, suggested prompts, or previous messages down the page. Each push is a layout shift, and if the user's cursor or a click lands during one of those shifts, it counts against CLS at the worst possible moment — mid-interaction.

  • Reserve a sensible minimum height for the response container before the first token arrives, based on typical response length.
  • Anchor scroll position to the bottom explicitly during streaming rather than relying on natural document flow to keep the latest content in view.
  • Animate new content in with transform and opacity, never by changing height or inserting above messages that already rendered.

Inserting a "thinking…" indicator above the growing response, then removing it once the first token arrives, is a guaranteed layout shift. Reserve its space as part of the response container instead of treating it as a separate element.

16msframe budget at 60fps
200msINP threshold for a "good" score
0.1CLS threshold for a "good" score

Measuring a page whose "load" isn't the point

Standard field tools still work here, but the interaction that matters most is the send-message-to-first-render cycle, not the initial page load. Treat each message turn as its own soft navigation for measurement purposes where your tooling supports it, and specifically watch INP during the streaming window, not just on the initial click that sent the message — a keystroke in the input box while a previous response is still streaming is exactly the interaction most likely to reveal a real INP problem.

Lab tools like Lighthouse often under-report this, because a scripted run doesn't type into the input field while a response streams. Field data, or a manual trace while interacting mid-stream, catches what a synthetic run misses.

Perceived performance: skeletons and optimistic UI

Not every fix here is a Core Web Vitals metric. The user's own sent message should render instantly and optimistically — before the server has even acknowledged it — because waiting for a round trip before showing what someone just typed reads as broken, even though it technically doesn't touch LCP, INP or CLS. Pair that with a lightweight, fixed-height typing indicator that occupies the exact space the first response tokens will need, and the interface feels responsive well before the model has produced anything at all.

A skeleton or typing indicator only helps perceived performance if it reserves the same space the real content will occupy. One that collapses the moment the response arrives just relocates the layout shift instead of preventing it.

Concurrent streams compound the same problem

Interfaces built on multi-step, tool-calling workflows often render more than one stream at once — a status line for the current tool call, the model's running commentary, and the eventual answer, all updating independently. Each additional stream is another source of token-driven DOM updates competing for the same main thread, so the batching and reserved-space fixes above matter more, not less, as an interface gets more agentic. Treat every simultaneous stream as its own rendering budget rather than assuming the fix for one chat bubble automatically covers a page updating three things at once.

You don't have to build the batching yourself

Most of this is a known problem with known primitives, not something to solve from scratch. React's useTransition and startTransition let you mark streaming text updates as lower priority than user input, so a keystroke in the input box can still interrupt a render instead of waiting behind it. Several LLM front-end SDKs already batch token updates internally rather than dispatching a state update per token. For long conversation histories, a virtualised list renders only the messages currently on screen, which keeps CLS and paint cost from growing as the conversation gets longer — the same technique any long-scrolling feed uses, applied to chat.

Before writing custom token-batching logic, check whether your streaming SDK or UI framework already does it. Reinventing this layer is a common source of the exact bugs it's meant to prevent.

A fix checklist

  1. Server-render or statically render the empty chat shell so first paint doesn't wait on JavaScript.
  2. Reserve height for the response container before the first token arrives.
  3. Batch token rendering inside requestAnimationFrame instead of appending on every token.
  4. Parse markdown and syntax highlighting incrementally, not on the full message every time.
  5. Anchor auto-scroll explicitly during streaming instead of relying on layout flow.
  6. Test INP by typing into the input while a response is actively streaming, not just on the initial send.

A model that answers in 200 milliseconds can still feel slow if the interface fights the browser for the rest of the interaction.

Where this fits with the rest of the pipeline

These are rendering problems, not model problems, which is exactly why they stay invisible to teams measuring only the rendering pipeline in the abstract, or only time-to-first-token on the model side. Fixing them doesn't require a different model or a faster provider — it requires treating the streaming response the same way you'd treat any other high-frequency UI update: batched, bounded, and never fighting layout for space it didn't reserve.


Optimise the shell like any other page for LCP, treat token streaming as a rendering-frequency problem for INP, and reserve space up front to kill CLS before it starts. None of these fixes touch the model — which is exactly why they're so often missed by teams who assume a Core Web Vitals problem must be a model-speed problem.

Frequently asked questions

Why does LCP look bad on a chat interface that has no real content at first paint?

Because LCP measures the largest element that actually paints, which on a fresh chat session is the app shell — input box, header, welcome state — not the eventual AI response. Optimise that shell for fast first paint the same way you would any other page.

Does streaming text token-by-token hurt Core Web Vitals?

Not inherently — it hurts INP and CLS specifically when each token triggers its own layout and paint pass. Batching updates inside requestAnimationFrame and reserving space for the response container removes most of the problem without changing the streaming itself.

How do I stop a chat widget from causing layout shift?

Reserve a sensible minimum height for the response container before the first token arrives, and avoid inserting or removing elements like a "thinking" indicator above content that has already rendered — both are common, avoidable sources of CLS in streaming interfaces.

  • #Core Web Vitals
  • #AI
  • #Performance

Latest updates

The four most recent posts across StackSignal.

All posts
Tech

Image Optimization for the Modern Web

A practical image optimization guide covering dimensions, modern formats, responsive images, loading priority and the mistakes that hurt LCP.

3 min read