DEV Community

Ganesh Joshi
Ganesh Joshi

Posted on Originally published at ganeshjoshi.dev

Core Web Vitals When Your Hero Section Is an AI Chat

This post was created with AI assistance and reviewed for accuracy before publishing.

Putting a live AI chat at the top of a landing page is a good demo and a bad hero. Every property that makes it compelling, that it is interactive, that it streams, that it responds to what you type, is also a property that Core Web Vitals penalises. The chat is usually the largest element on screen, so it becomes the LCP element. It grows as tokens arrive, so it moves everything under it. And it is the first thing a visitor touches, so it owns your INP.

None of that is unavoidable. It just means the hero has to be designed backwards from the metric.

The chat should not be your LCP element

LCP measures when the largest block of content in the viewport finishes rendering. If that block is a chat transcript that appears after hydration, your LCP cannot happen until JavaScript has downloaded, parsed, and run. On a mid-range phone over slow 4G that is several seconds you do not control.

The fix is to make something else the largest element, and to make that thing static.

In practice this means the hero is a headline, a subhead, and a chat panel that is present but visually secondary. Render the headline server-side as real text. Text has no decode step, no network request beyond the HTML itself, and it is eligible to be the LCP element the moment the document paints.

// The h1 is the LCP candidate. It ships in the HTML and needs no JS.
<section className="hero">
  <h1>Ask anything about your codebase</h1>
  <p>Answers grounded in your own repository, not the public internet.</p>
  <ChatPanel />
</section>
Enter fullscreen mode Exit fullscreen mode

If the design genuinely requires the chat to dominate the fold, then pre-render its first state on the server: a greeting message, a couple of example prompts, the input box. That markup is real content, it counts for LCP, and it arrives with the document.

Reserve the space before the tokens arrive

Streaming output is the most reliable way to generate layout shift. Each chunk makes the transcript taller, and everything below it moves down. That is textbook CLS, and unlike LCP it keeps accumulating for as long as the response runs.

Give the transcript a fixed height, or a min-height large enough for a typical answer, and let it scroll internally:

.chat-transcript {
  min-height: 22rem;      /* space is reserved before the first token */
  overflow-y: auto;
  overscroll-behavior: contain;
}
Enter fullscreen mode Exit fullscreen mode

Two related mistakes are worth naming because they look harmless.

The first is animating the container open as the response starts. Transitioning height or width recalculates layout on every frame and shifts whatever sits below. If you want the panel to feel like it is opening, animate transform instead. transform and opacity are the only two properties the compositor can handle without touching layout.

The second is the typing indicator. A three-dot bubble that is inserted, then removed, then replaced by the answer causes two shifts on its own. Render it inside the space you already reserved, and swap its contents rather than its box.

INP belongs to the input box, not the model

INP measures the delay between an interaction and the next frame the browser paints. It does not measure how long your model takes to answer. This distinction is worth internalising, because it means a slow model does not have to mean a bad INP score.

What ruins INP is doing work on the main thread inside the event handler. Tokenising the prompt, building embeddings client-side, or serialising a large conversation history before the request goes out all block the frame.

Keep the handler to the minimum that produces visible feedback:

function onSubmit(text: string) {
  // Paint first: echo the user's message and clear the input.
  appendMessage({ role: 'user', text });
  setInput('');

  // Everything expensive happens after, off the interaction's critical path.
  void sendToModel(text);
}
Enter fullscreen mode Exit fullscreen mode

The user sees their message appear immediately. The request, the streaming, and the rendering of the reply all happen afterwards, and none of it is attributed to that interaction.

Watch TTFB if the route is server-rendered

If the page server-renders anything that depends on a model or a vector database, that work sits in front of your TTFB, and TTFB is inside LCP. A route that waits 400ms on a similarity search before it can send a byte has spent 400ms of an LCP budget that is roughly 2.5 seconds on mobile.

The hero almost never needs that data. Render the shell immediately and let the dynamic part stream in or load client-side. In frameworks with streaming SSR, put the model-dependent region inside a boundary so the rest of the document flushes without waiting for it.

Measure in the field, not on your laptop

Lab tools are useful for finding causes and useless for judging severity. Your development machine has a fast CPU, a warm cache, and a local server, which is the combination least likely to reproduce what a phone on a train experiences.

Two habits are worth building:

Habit Why it matters
Read field data, not lab scores LCP and CLS are scored at the 75th percentile of real visits
Confirm which element is actually LCP It is often not the one you assumed, and optimising the wrong element wastes the work

The second one catches more mistakes than any other check. You can log it directly:

new PerformanceObserver((list) => {
  const entry = list.getEntries().at(-1);
  console.log('LCP element:', entry.element, Math.round(entry.startTime), 'ms');
}).observe({ type: 'largest-contentful-paint', buffered: true });
Enter fullscreen mode Exit fullscreen mode

Run that on the deployed site, on a throttled connection, and see what it names. If it points at your chat transcript, the rest of this post is the work list. If it points at the headline, the hero is already shaped correctly and your budget is going somewhere else.

The short version

An AI chat hero is worth building. It just cannot be the thing the browser is waiting on. Make static text the largest element, reserve the transcript's space before the first token lands, keep the submit handler down to visible feedback, and stream anything the model touches instead of blocking the document on it. The demo stays impressive and the metrics stop paying for it.

Top comments (0)