Core Web Vitals for AI Chat Interfaces: LCP, INP and CLS in Streaming UIs
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 approach | Main-thread cost | INP risk |
|---|---|---|
| Append and re-render on every token | One layout/paint pass per token | High — input lags during streaming |
| Batch tokens with requestAnimationFrame | One pass per frame, ~60/sec max | Low — bounded by the frame budget |
| Buffer and flush every N tokens or M ms | Configurable, lowest cost | Lowest — 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
transformandopacity, 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.
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
- Server-render or statically render the empty chat shell so first paint doesn't wait on JavaScript.
- Reserve height for the response container before the first token arrives.
- Batch token rendering inside
requestAnimationFrameinstead of appending on every token. - Parse markdown and syntax highlighting incrementally, not on the full message every time.
- Anchor auto-scroll explicitly during streaming instead of relying on layout flow.
- 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.