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.
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.
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-vitalsattribution 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.
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.
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.
Related
- Core Web Vitals for Client-Side Apps β how LCP fits alongside CLS and INP in a SPA.
- Incremental & Streaming SSR for SEO β flush the hero before the rest of the page resolves.
- Improving INP on JavaScript-Heavy SPA Pages β the responsiveness metric that hydration also degrades.
β Back to Core Web Vitals for Client-Side Apps