Reducing LCP for Client-Rendered Hero Content

The hero is almost always a page’s Largest Contentful Paint element, and in a single-page app it is almost always painted late. This guide, part of Core Web Vitals for client-side apps, explains why a JavaScript-rendered hero produces a poor Largest Contentful Paint (LCP) and how preloading, server rendering, and streaming move that paint into the first response.

Problem Statement

LCP measures the time from navigation to the paint of the largest element in the viewport. In a client-rendered app that element β€” the hero image, headline, or banner β€” does not exist in the DOM when the document arrives. It is created only after the browser downloads the JavaScript bundle, parses and executes it, builds the component tree, and commits the first render. If the hero also depends on a client-side data fetch, add a network round-trip on top.

The result is a chain of serial dependencies, each of which the browser cannot skip:

  • Bundle download and parse. No hero markup exists until the framework runs.
  • Hydration. The component tree must build before the hero component renders.
  • Data fetch. A hero that reads from an API paints only after that request resolves.
  • Image request. The image URL is discoverable only after the element is inserted, so its download starts late.

Each link pushes the largest paint later, which is why a hero that feels instant on a fast local machine still records a multi-second LCP on a mid-tier phone over mobile data. And because the same deferred execution governs whether the crawler’s render completes, a client-only hero is also a candidate for never being indexed at all β€” the risk quantified in JavaScript execution limits and crawl budget.

Two paths to the hero paint: client-only versus server-rendered plus preload The top track chains bundle, hydration, fetch, and image download before a late LCP; the bottom track ships the hero in the first response and preloads its image for an early LCP. Client-only hero β€” serial waits push LCP late bundle hydrate fetch data image load LCP late Server-rendered hero + preload β€” paint in the first response HTML ships hero markup preload starts image early browser paints during layout LCP early, no hydration wait
The client-only path chains four serial waits before the hero paints; server-rendering the markup and preloading the image collapses them into the first response.

Step-by-step fix

1. Preload the LCP image so its download is not blocked

In a SPA the browser cannot discover the hero image from the initial HTML, so the download does not start until the bundle inserts the element. A preload link in the head lets the request begin during the initial parse. Preload only the one LCP image and mark it high priority.

<!-- In the document head, so the browser fetches the hero during parse,
     not after the bundle runs and inserts the <img>. -->
<link rel="preload" as="image" href="/media/hero-1200.avif" fetchpriority="high">

2. Give the LCP element its own fetch priority

Even when the element is discoverable, browsers may assign it a default priority. Mark the hero image explicitly and let non-critical images stay lazy so bandwidth flows to the paint that matters.

// The LCP image is eager and high priority; everything else defers.
<img
  src="/media/hero-1200.avif"
  width={1200}
  height={630}
  fetchPriority="high"
  loading="eager"
  decoding="async"
  alt="Product overview"
/>

3. Server-render or stream the hero markup

Preloading accelerates the image, but the element still appears only after hydration unless you render it on the server. Emit the hero in the first HTML response so the browser can paint it during the initial layout, then hydrate the interactive parts around it. Streaming lets you flush the hero before the rest of the page is ready.

import { renderToPipeableStream } from "react-dom/server";

function handler(req: Request, res: ServerResponse): void {
  const { pipe } = renderToPipeableStream(<App url={req.url} />, {
    // Flush the shell β€” including the hero markup β€” as soon as it is ready,
    // so the browser paints the LCP element before data-heavy sections resolve.
    onShellReady() {
      res.setHeader("content-type", "text/html");
      pipe(res);
    },
  });
}

Choosing where the hero renders is also a rendering-architecture decision; the trade-offs between full, partial, and streaming approaches are laid out in hydration strategies compared, and streaming specifically in incremental and streaming SSR for SEO.

Decision tree for where a client-rendered hero should render Three sequential questions route the hero to server-side data resolution, image preload, or server rendering, and only a hero that passes all three paints early in the first response. Route the hero to the fix it needs Reads client-only data? hero waits on a client fetch yes Resolve data server-side or bake it no Discoverable in initial HTML? image URL visible before the bundle runs no Preload image, fetchpriority high yes Element server-rendered? markup in the first response no Server-render or stream the markup yes Early LCP β€” paints in first response
Each question that fails routes the hero to a specific fix; a hero that passes all three already paints during the initial layout.

4. Keep the hero off the client-only data path

If the hero reads from an API on the client, its paint is gated on that request. Move the data server-side and embed it in the initial render, or statically bake it, so the hero never waits for a client fetch.

// Anti-pattern: hero blocked on a client fetch β€” paints only after the round-trip.
function HeroClientOnly() {
  const { data } = useQuery("/api/hero"); // paint waits for this
  if (!data) return <Placeholder />;
  return <Hero title={data.title} image={data.image} />;
}

// Preferred: data resolved server-side and passed as props β€” hero paints immediately.
function Hero({ title, image }: { title: string; image: string }) {
  return (
    <header>
      <img src={image} width={1200} height={630} fetchPriority="high" alt="" />
      <h1>{title}</h1>
    </header>
  );
}

Validation

Confirm the hero is now the early paint you expect:

  • Attribute the LCP element. The web-vitals attribution build reports which element was the LCP and how its time breaks down across load delay, render delay, and resource load, so you can see whether hydration or the image is the bottleneck.
import { onLCP, type LCPMetricWithAttribution } from "web-vitals/attribution";

onLCP((metric: LCPMetricWithAttribution) => {
  const a = metric.attribution;
  console.assert(metric.value < 2500, `LCP too slow: ${metric.value}ms`);
  console.log(a.element, a.resourceLoadDelay, a.elementRenderDelay);
});
  • Read the LCP marker in DevTools. In the Performance panel, the LCP marker should land during the initial paint, not after the hydration task. If it sits after a long scripting block, the hero is still client-gated.
  • Confirm the preload fired early. In the Network panel the hero image should start during the initial HTML parse, near the top of the waterfall, not after the main bundle.
LCP attribution broken into its sub-parts for two rendering approaches A client-only hero stacks time-to-first-byte, bundle load delay, hydration render delay and image load past the 2.5 second threshold, while a server-rendered preloaded hero lands well under it. LCP attribution: where the time goes 2.5s good threshold Client-only hero 3.4s SSR + preload 1.4s TTFB bundle load delay render / hydration delay image resource load
The attribution build splits LCP into these segments, so you can see whether hydration delay or the image download is the part pushing you past 2.5 seconds.

Common Pitfalls

  • Preloading too much. Preloading many resources spreads priority thin and can delay the very image you meant to accelerate; preload only the single LCP asset.
  • Lazy-loading the hero. loading="lazy" on the LCP image defers the exact resource that decides the score; keep the hero eager and lazy-load only below-the-fold media.
  • A hero behind a client fetch. Reading hero data with a client query gates the paint on a round-trip; resolve it server-side or bake it in.
  • Server-rendering the markup but not the image reference. If the SSR hero points at an image the client swaps later, you lose the preload benefit; render the final image URL on the server.
  • Ignoring layout stability. Painting the hero earlier is wasted if it then shifts; reserve its box, as covered in fixing layout shift during SPA route transitions.
Five habits that keep a client-rendered hero painting late Preloading too much, lazy-loading the hero, gating the hero on a client fetch, server-rendering markup but swapping the image, and ignoring layout stability each delay or waste the largest paint. Each habit, and what it does to LCP Pitfall Effect on LCP Preloading too much priority spread thin, hero download slips Lazy-loading the hero image defers the exact image that sets the score Hero behind a client fetch paint waits on an API round-trip SSR markup, client-swapped image preload benefit is thrown away Ignoring layout stability the early paint shifts and is wasted
The pitfalls all delay or waste the hero paint; each maps to a concrete effect on the LCP timing.

Frequently Asked Questions

Why is LCP so high when my hero looks like it loads instantly? If the hero is rendered by JavaScript, it does not exist in the DOM until the bundle downloads, executes, and hydrates, and often until a data fetch resolves. Largest Contentful Paint is timed from navigation to that paint, so even a fast-feeling hero registers a late LCP because the browser could not paint it any earlier than the JavaScript allowed.

Should I preload the hero image in a client-rendered app? Preload it only when the browser cannot discover it from the initial HTML, which is the usual case in a SPA where JavaScript inserts the image later. A preload link in the document head lets the browser start the download during the initial parse instead of after the bundle runs. Preload just the single LCP image, because preloading many resources dilutes the priority you were trying to grant.

Does server-side rendering the hero improve LCP more than preloading? Usually yes. Preloading only accelerates the download of a resource the client will still insert; server-rendering or streaming the hero puts the actual element in the first response, so the browser can paint it during the initial layout without waiting for hydration. Preloading and server rendering are complementary β€” server-render the markup and preload the image it references.

← Back to Core Web Vitals for Client-Side Apps