All Blogs
    Development12 min read

    FCP and LCP Explained: Common Problems and How to Fix Them

    A deep dive into First Contentful Paint and Largest Contentful Paint: what they measure, how to diagnose them, the problems that make them slow, and the fixes that actually move the numbers.

    Visitors judge your site in the first few seconds, long before they click anything. Two metrics describe those seconds better than any other: First Contentful Paint (FCP), the moment something appears, and Largest Contentful Paint (LCP), the moment the main content appears. When either one is slow, the page feels broken, even if everything technically works.

    This article explains what each metric measures, how to find out why yours is slow, and how to fix the most common problems. The examples use plain HTML and Next.js, but the ideas apply to any stack.

    What FCP measures

    FCP is the time from when the user starts navigating to the page until the browser renders the first piece of content: text, an image (including background images), an SVG, or a non-white <canvas>. It answers one question from the user's point of view: "Is anything happening?"

    • Good: 1.8 seconds or less
    • Needs improvement: between 1.8 and 3.0 seconds
    • Poor: more than 3.0 seconds

    FCP is not one of the three Core Web Vitals, but it is the best early warning you have. Everything that delays FCP also delays LCP, because nothing can be the largest element before anything has been painted at all.

    What LCP measures

    LCP is the time until the largest image or text block visible in the viewport is rendered. It is a Core Web Vital, so it affects search ranking, and it is a much better proxy for "the page is useful now" than older metrics like load or DOMContentLoaded.

    • Good: 2.5 seconds or less
    • Needs improvement: between 2.5 and 4.0 seconds
    • Poor: more than 4.0 seconds

    Google evaluates the 75th percentile of real page loads, split by mobile and desktop. In other words, three out of four of your visitors need a good experience, not just the ones on fast laptops.

    Which elements can become the LCP element

    • <img> elements, and <image> elements inside an <svg>
    • <video> elements (the poster image or the first displayed frame)
    • Elements with a background image loaded through CSS url()
    • Block-level elements that contain text, such as headings and paragraphs

    The browser keeps reporting new candidates as larger elements appear, and stops as soon as the user interacts with the page (a tap, scroll, or key press). Elements with opacity: 0 are ignored, and so are very low-detail images such as blurry placeholders, because they do not show real content.

    How to measure FCP and LCP

    You need two kinds of data. Lab data from Lighthouse or the Chrome DevTools Performance panel is reproducible and great for debugging. Field data from real users, such as the Chrome UX Report shown in PageSpeed Insights or your own analytics, tells you what visitors actually experience. When the two disagree, trust the field data and use the lab to find the cause.

    Both metrics are exposed directly by the browser, so you can watch them with a PerformanceObserver. Paste this into the DevTools console and reload the page:

    js
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        console.log("FCP", Math.round(entry.startTime), "ms");
      }
    }).observe({ type: "paint", buffered: true });
    
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lcp = entries[entries.length - 1]; // the latest candidate wins
      console.log("LCP", Math.round(lcp.startTime), "ms", lcp.element);
    }).observe({ type: "largest-contentful-paint", buffered: true });

    The paint observer also reports first-paint, so check that the entry name is first-contentful-paint if you log more than the time. For production monitoring, use the web-vitals library. Its attribution build tells you not only how slow LCP was, but which element it was and where the time went:

    web-vitals.jsjs
    import { onFCP, onLCP } from "web-vitals/attribution";
    
    onFCP(({ value, attribution }) => {
      console.log("FCP", value, "TTFB", attribution.timeToFirstByte);
    });
    
    onLCP(({ value, attribution }) => {
      console.log("LCP", value, attribution.target, {
        ttfb: attribution.timeToFirstByte,
        loadDelay: attribution.resourceLoadDelay,
        loadDuration: attribution.resourceLoadDuration,
        renderDelay: attribution.elementRenderDelay,
      });
    });

    Break LCP into four parts

    "LCP is slow" is not actionable. "Our LCP image starts downloading 1.2 seconds late" is. Every LCP time can be split into four consecutive parts, and each one has different causes and fixes:

    1. Time to First Byte (TTFB): from navigation start until the first byte of HTML arrives.
    2. Resource load delay: from TTFB until the browser starts downloading the LCP resource (usually an image). Zero for text.
    3. Resource load duration: how long the LCP resource takes to download. Zero for text.
    4. Element render delay: from the end of the download until the element is actually painted.

    On a well-optimized page, most of the time goes to TTFB and the image download (roughly 40% each), while load delay and render delay stay under 10% each. If either delay is large, that is where your quick wins are. The Performance panel in Chrome DevTools shows this exact breakdown in its LCP insight, so you do not need to calculate it by hand.

    Problem 1: slow server response (high TTFB)

    Nothing can render before the HTML arrives, so TTFB sets the floor for both FCP and LCP. Common causes are rendering every page on every request, slow database or API calls, long redirect chains, and serving users from a single region far away from them.

    • Pre-render pages that look the same for everyone. Static HTML on a CDN is the fastest TTFB you can get. In Next.js, use generateStaticParams for dynamic routes, and revalidate when content changes over time.
    • Cache expensive data instead of fetching it on every request.
    • Remove redirects from important entry points, such as http to https to www chains, and link directly to the final URL.
    • Stream the response so the browser can start on the HTML shell while slow data is still loading.
    app/dashboard/page.tsxtsx
    import { Suspense } from "react";
    
    export default function Dashboard() {
      return (
        <>
          <PageHeader /> {/* sent immediately, painted early */}
          <Suspense fallback={<StatsSkeleton />}>
            <SlowStats /> {/* streamed in when the data is ready */}
          </Suspense>
        </>
      );
    }

    Streaming improves FCP a lot. It only helps LCP if the LCP element is part of the shell, so keep your hero content outside slow Suspense boundaries.

    Problem 2: render-blocking CSS and JavaScript

    Browsers will not paint until they have downloaded and parsed every stylesheet in the <head>, and every synchronous <script> without async or defer. Each of those files is a round trip that sits directly on the path to FCP.

    html
    <!-- Blocks parsing and painting until downloaded and executed -->
    <script src="/analytics.js"></script>
    
    <!-- Downloads in parallel and runs after the HTML is parsed -->
    <script src="/analytics.js" defer></script>
    • Add defer (or async for independent scripts) to every script that is not needed for the first paint. In Next.js, load third-party scripts with next/script and strategy="lazyOnload" or "afterInteractive".
    • Keep CSS small. Remove unused styles, avoid huge CSS frameworks you barely use, and do not @import one stylesheet from another, because each import adds another sequential request.
    • Avoid loading the same CSS twice from different bundles or CDNs.

    Problem 3: web fonts that hide text

    By default, many browsers hide text for up to three seconds while a custom font downloads. Your heading might be ready to paint, but the user sees an empty space, and both FCP and LCP wait for the font. Loading fonts through a CSS @import from Google Fonts makes this worse, because the browser first has to fetch the stylesheet and only then discovers the font files.

    css
    @font-face {
      font-family: "Syne";
      src: url("/fonts/syne-800.woff2") format("woff2");
      font-weight: 800;
      font-display: swap; /* show fallback text immediately, swap when ready */
    }

    In Next.js, next/font does all of this for you: it self-hosts the font, adds font-display: swap, preloads the files, and generates a size-adjusted fallback font so the swap does not shift the layout. On this site, replacing a Google Fonts @import with next/font removed a render-blocking request from every page.

    Problem 4: the LCP image is discovered too late

    This is the most common cause of a slow LCP, and it shows up as a large resource load delay. The browser's preload scanner finds images in the HTML very early, but it cannot see images that are:

    • Set as a CSS background-image, which is only found after the stylesheet is downloaded and applied.
    • Inserted by JavaScript, for example in a client-side rendered component or a carousel library.
    • Marked with loading="lazy", which tells the browser to wait until layout confirms the image is in the viewport.

    Lazy loading is great for images below the fold and harmful for the hero image. Make the LCP image part of the initial HTML, load it eagerly, and tell the browser it matters with fetchpriority="high":

    html
    <!-- Bad: discovered late and deprioritized -->
    <img src="/hero.jpg" loading="lazy" alt="Product preview" />
    
    <!-- Good: discovered by the preload scanner and fetched first -->
    <img
      src="/hero-1200.avif"
      srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-2000.avif 2000w"
      sizes="(min-width: 1024px) 50vw, 100vw"
      width="1200"
      height="800"
      fetchpriority="high"
      alt="Product preview"
    />

    If the image really has to be a CSS background, preload it in the <head> so the download starts immediately:

    html
    <link rel="preload" as="image" href="/hero-1200.avif" fetchpriority="high" />

    With next/image, add the preload prop to the LCP image (it was called priority before Next.js 16). Next.js then skips lazy loading for that image and adds the preload hint for you:

    tsx
    import Image from "next/image";
    import hero from "@/assets/images/hero.jpg";
    
    <Image src={hero} alt="Product preview" sizes="(min-width: 1024px) 50vw, 100vw" preload />

    Use preload and high priority for one or two images at most. If you mark everything as important, nothing is.

    Problem 5: the LCP image is too heavy

    A large resource load duration means the file itself takes too long to download. The usual suspects are images that are much larger than their display size, old formats, and images served from a slow or distant server.

    • Serve the size the layout actually needs. Use srcset and an accurate sizes attribute so phones do not download a desktop-sized image.
    • Use modern formats. AVIF and WebP are usually far smaller than JPEG or PNG at the same visual quality.
    • Compress properly. A hero image rarely needs more than about 75 to 80 quality.
    • Serve images from a CDN, ideally from your own domain. Images on a separate domain need an extra connection; if you cannot avoid that, add <link rel="preconnect"> for that origin.

    next/image handles resizing, modern formats, and caching automatically. You still have to provide a correct sizes value, which is the part most people forget.

    Problem 6: client-side rendering

    A classic single-page app ships an almost empty HTML file, something like <div id="root"></div>, and builds the page in JavaScript. The browser has nothing to paint until the JavaScript bundle is downloaded, parsed, and executed, and any data it needs is fetched. FCP and LCP both wait for the whole chain.

    Rendering on the server fixes this at the root. With Server-Side Rendering or Static Site Generation, the HTML already contains your heading, text, and image tags, so the browser can paint them while JavaScript is still loading. Hydration makes the page interactive later, but it does not block the first paint. This is one of the biggest reasons this portfolio moved from a client-rendered Vite app to Next.js.

    Problem 7: high element render delay

    Sometimes the image has finished downloading, or the text is already in the HTML, and the browser still does not paint it. That gap is the element render delay. Typical causes:

    • Render-blocking stylesheets or scripts that are still loading (see problem 2).
    • Long JavaScript tasks on the main thread that keep the browser busy during load.
    • Content hidden until JavaScript runs, such as "anti-flicker" snippets used by A/B testing tools.
    • Entrance animations that start the LCP element at opacity: 0.

    The last one is easy to miss on beautifully animated sites, including this one. An element painted at opacity: 0 is not an LCP candidate, so a hero heading that fades in can distort the metric in two ways. Either it only counts once it becomes visible, which adds the animation delay to your LCP, or the browser skips it entirely and reports a smaller element instead. When I measured this site in Chrome, LCP pointed at the small logo text in the navbar rather than the hero heading: a great-looking number that said nothing about when visitors actually saw the main content. Keep the LCP element visible from the first frame and animate movement instead:

    css
    /* Distorts LCP: the heading is invisible until the animation runs */
    @keyframes fade-up {
      from { opacity: 0; transform: translateY(24px); }
      to { opacity: 1; transform: translateY(0); }
    }
    
    /* Keeps LCP honest and fast: the heading is painted immediately and only moves */
    @keyframes slide-up {
      from { transform: translateY(24px); }
      to { transform: translateY(0); }
    }
    
    @media (prefers-reduced-motion: reduce) {
      .hero-title { animation: none; }
    }
    Animate the content around your LCP element, not the LCP element itself.

    Problem 8: the wrong element becomes the LCP

    Always check which element is your LCP before optimizing. It is often not the one you expect. A large cookie banner, a promotional pop-up, or a big block of body text on mobile can be larger than your hero image. Optimizing the hero does nothing if the cookie banner is the LCP element and it is injected late by a third-party script.

    Use the attribution.target value from web-vitals, or the LCP marker in the DevTools Performance panel, to see the actual element. Then optimize that one. Remember that the answer can differ between mobile and desktop, because the viewport changes which element is largest.

    A step-by-step workflow

    1. Check field data in PageSpeed Insights to see whether FCP or LCP is actually a problem for real users, and on which device type.
    2. Find the LCP element for the slow page on that device type.
    3. Break the LCP time into TTFB, load delay, load duration, and render delay.
    4. Fix the largest part first: TTFB with static rendering and caching, load delay with discoverability and fetchpriority, load duration with smaller images, and render delay with fewer blocking resources and no hidden content.
    5. Ship the change, then confirm the improvement in field data. Field data updates over days, not minutes, so keep monitoring.

    FCP and LCP are not mysterious scores. They are a timeline of what the browser does, and every millisecond belongs to a specific step. Once you know which step is slow, the fix is usually small: an attribute on an image, a defer on a script, or a page that is rendered ahead of time instead of on every request.

    Web PerformanceCore Web VitalsFCPLCPNext.js
    Back to Blogs