LCP React 18+

Fix LCP in React

Largest Contentful Paint is the hardest Core Web Vital to optimize in client-rendered React applications because the LCP element is typically rendered by JavaScript -- meaning the browser must download, parse, and execute your bundle before it can paint the element. This guide covers the highest-impact fixes, from image preloading to server-side rendering, with practical code examples.

Expected results

Before

3.8s

LCP (Poor) -- client-side render, no image preload, large bundle

After

1.7s

LCP (Good) -- SSR with image preload, code splitting, inline critical CSS

Step-by-step fix

Preload the LCP image

In a client-side React app, the LCP image is often discovered late because it is injected by JavaScript after the bundle executes. Add a <link rel="preload"> tag for the LCP image directly in your HTML template so the browser fetches it at the earliest possible moment -- before React even starts.

HTML -- index.html head
<!-- In your public/index.html <head> -->
<!-- Preload the hero image so browser fetches it immediately -->
<link
  rel="preload"
  as="image"
  href="/images/hero.webp"
  type="image/webp"
  fetchpriority="high"
/>

<!-- For React apps using react-helmet-async -->
import { Helmet } from 'react-helmet-async';

function HeroSection() {
  return (
    <>
      <Helmet>
        <link
          rel="preload"
          as="image"
          href="/images/hero.webp"
          fetchpriority="high"
        />
      </Helmet>
      <img src="/images/hero.webp" alt="Hero" width={1200} height={630} />
    </>
  );
}

Add SSR or static rendering

The most impactful LCP improvement for React apps is moving the LCP element out of client-side JavaScript and into server-rendered HTML. Pure CSR (create-react-app style) forces the browser to run JS before anything renders. With SSR, the browser paints immediately from the HTML stream.

TypeScript -- Express SSR example
// server.js -- Basic React SSR
import express from 'express';
import { renderToString } from 'react-dom/server';
import App from './src/App';

const app = express();

app.get('*', (req, res) => {
  // HTML is sent immediately -- LCP element is in the initial payload
  const html = renderToString(<App url={req.url} />);
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <link rel="preload" as="image" href="/hero.webp" />
        <link rel="stylesheet" href="/styles.css" />
      </head>
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js" defer></script>
      </body>
    </html>
  `);
});

// Or migrate to Next.js / Remix which handle this automatically

Reduce JavaScript bundle size

Every kilobyte of JavaScript delays rendering. Use React.lazy() and Suspense to code-split routes and below-the-fold components. Analyze the bundle with webpack-bundle-analyzer to identify large dependencies. Common wins: replacing moment.js with date-fns, removing polyfills for modern browsers, and tree-shaking lodash.

JSX -- React.lazy code splitting
import React, { lazy, Suspense } from 'react';

// Split routes -- each loads separately
const HomePage = lazy(() => import('./pages/HomePage'));
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));

// Split heavy below-fold components
const CommentsSection = lazy(() => import('./components/Comments'));
const RecommendationsGrid = lazy(() => import('./components/Recommendations'));

function App() {
  return (
    <Router>
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/dashboard" element={<DashboardPage />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

Set image dimensions to prevent CLS that delays LCP

Images without dimensions cause CLS (layout shift) which forces re-layout and can reset or delay LCP measurement. Always include width and height attributes on every <img> element, and pair with height: auto in CSS for responsive sizing.

JSX -- Image with dimensions
// Always include width and height to prevent layout shifts
function HeroImage() {
  return (
    <img
      src="/hero.webp"
      alt="Product showcase"
      width={1200}
      height={630}
      // CSS makes it responsive while browser reserves correct space
      style={{ width: '100%', height: 'auto' }}
      // fetchpriority tells browser to prioritize this image
      fetchPriority="high"
    />
  );
}

// For background-image LCP elements, use a real img tag instead
// Background images have lower priority and are discovered later

Minimize render-blocking CSS

CSS that loads in <link> tags blocks rendering. Only the critical CSS needed for above-the-fold content should block render; everything else should be loaded asynchronously. Extract critical CSS inline and load the full stylesheet asynchronously.

HTML -- Non-render-blocking CSS
<!-- Critical CSS inline in <head> -- blocks render but small -->
<style>
  /* Only styles needed for above-the-fold content */
  body { margin: 0; font-family: system-ui, sans-serif; }
  .hero { position: relative; aspect-ratio: 16/9; }
  .hero img { width: 100%; height: auto; }
  .nav { height: 60px; background: #0f172a; }
</style>

<!-- Full stylesheet loaded async -- does not block LCP -->
<link
  rel="stylesheet"
  href="/styles.css"
  media="print"
  onload="this.media='all'"
/>
<noscript><link rel="stylesheet" href="/styles.css"></noscript>

Quick checklist

  • LCP image has fetchpriority="high" or a <link rel="preload">
  • App uses SSR, SSG, or at minimum inline-renders the LCP element in the HTML
  • All images have width and height attributes
  • Below-the-fold components use React.lazy()
  • Critical CSS is inline or render-blocking stylesheet is minimal
  • Third-party scripts use defer or async attribute

Frequently asked questions

React SPAs have poor LCP because the LCP element is rendered client-side -- the browser must first download, parse, and execute JavaScript before React renders anything. LCP is blocked by the full JS load time. Solutions: SSR, static rendering, or ensuring the LCP element is in the server-rendered HTML. Moving to SSR typically cuts LCP by 50-70%.

Yes, significantly. With SSR the browser receives rendered HTML immediately and the LCP element is painted before any JavaScript loads. React then hydrates the HTML, but LCP is already captured. Moving from a pure CSR SPA to SSR typically cuts LCP by 50-70%.

Add a <link rel="preload" as="image" href="/path/to/image.webp"> tag in the <head> of your HTML template. In React apps, use react-helmet-async or inject it in your index.html. The browser starts fetching before the React component tree renders.

Yes, if used on above-the-fold components. React.lazy() delays loading the component until needed -- if the LCP element is inside a lazy-loaded component, LCP will be poor because the browser waits for the lazy bundle. Only use React.lazy() for below-the-fold or on-demand content.

With SSR or static rendering, aim for under 2.0s. With client-side-only rendering, even highly optimized apps rarely achieve under 2.5s due to JavaScript execution overhead. Google's good threshold is 2.5s at the 75th percentile -- CSR React apps frequently fail this without SSR or prerendering.

Google rates LCP as 'good' when it is under 2.5 seconds at the 75th percentile. For React applications specifically, aim for under 2.0 seconds. Measure with field data from Chrome User Experience Report (CrUX) through PageSpeed Insights, as lab tests may not reflect real-user experience with third-party scripts and varying network conditions.

Continue learning