Skip to content

    Frontend System Design

    Optimizing Largest Contentful Paint

    Find your page's LCP element, split LCP into its four parts, and choose fixes for discovery, priority, bytes and render delay, with trade-offs for each.

    By Published 15 min read

    • frontend-system-design
    • core-web-vitals
    • lcp
    • image-loading
    Share

    Standard lessonintermediateFrontend System Design

    Before this: Loading Performance and Core Web Vitals

    What you'll learn: By the end of this lesson you will be able to say which element counts as the Largest Contentful Paint element and why it can change, split a slow LCP into its four parts, pick the right fix for each part (discoverability, fetchpriority, preload, lazy loading, image bytes, render-blocking work, first byte), and spot cases where the metric looks good for the wrong reason.

    Think of a magazine rack. A reader decides in a moment whether to pick up a magazine, and what they judge is the biggest picture on the cover. If the cover is printed late, the quality of the articles inside does not help.

    The Largest Contentful Paint (LCP) metric puts a number on that moment for a web page: when the biggest piece of content in view was painted. Loading Performance and Core Web Vitals introduced LCP next to INP and CLS. This lesson goes deeper on LCP alone: how the browser picks the element, how to find the slow part, and what each fix costs.

    The running example is a category landing page of an online store: a large hero banner, a promotional heading and a grid of product cards. Marketing replaces the hero image every week.

    Facts below were read on 2026-11-08 from web.dev (the LCP page, last updated 4 September 2025, and the optimization guide, last updated 31 March 2025) and the W3C Largest Contentful Paint Working Draft of 26 August 2026. At the time of writing web.dev rates LCP "good" at 2.5 seconds or less and "poor" above 4.0 seconds, at the 75th percentile of page loads, segmented across mobile and desktop. Drafts and browser behaviour change; check before you build alerts on details.

    The core idea

    LCP is the render time of one element: the largest content element that has been painted in the viewport so far, measured from the start of navigation. Two consequences drive everything in this lesson.

    1. You must know which element it is. Optimizing the hero image does nothing if the LCP element on a phone is a block of text.
    2. The time is a chain, not a single cost. The element has to be found, requested, downloaded and painted, and each link has a different cause and a different fix. Web.dev splits LCP into four parts: time to first byte (TTFB), resource load delay, resource load duration and element render delay.

    First find the element, then the largest of the four parts, then fix that part. Anything else is guessing.

    Which element is the LCP element

    The browser watches paints and keeps the biggest eligible element it has seen.

    Figure 1. How an element becomes (or stays) the LCP element

    flowchart TD accTitle: How the browser decides on the LCP element accDescr: Each painted element is checked for type and for exclusions, sized by its visible area, and compared with the current LCP element. A larger one produces a new LCP entry, otherwise the current one stays. A["Element is painted in the viewport"] --> B{"Image, video poster,
    url background or text block?"} B -->|"no"| X["Not a candidate"] B -->|"yes"| C{"Excluded? Opacity 0,
    full viewport, low content"} C -->|"yes"| X C -->|"no"| D["Size = visible area
    (images: smaller of visible and intrinsic)"] D --> E{"Larger than the
    current LCP element?"} E -->|"yes"| F["New LCP entry reported"] E -->|"no"| G["Current LCP element stays"]

    How to read this: LCP is a running maximum. Every larger element that finishes painting replaces the earlier one, until the user interacts.

    What the sources say about each step:

    • Types. <img> (first frame for animated images), <image> inside <svg>, <video> (poster or first frame), elements with a CSS url() background (not gradients) and block-level elements containing text.
    • Exclusions. Chromium skips elements with opacity 0, elements covering the whole viewport (likely a background) and low-entropy placeholder images. The W3C draft states the same ideas as steps: an image whose response is smaller than the area times 0.004 bytes is skipped, as is an element whose size equals the viewport. It is a working draft, so treat those numbers as current text, not a contract.
    • Size. Only the part inside the viewport counts. For a resized image, web.dev says the smaller of the visible and intrinsic sizes is used, so upscaling a small image does not make it a bigger candidate. For text, the smallest rectangle containing the text nodes; margins, padding and borders are ignored.
    • Not yet rendered. An image that has not loaded, or text still in the font block period (invisible fallback), is not a candidate.
    • Removal. Removing an element does not reset the metric: it stays the largest "unless a larger element is rendered".
    • Stopping. Reporting stops at the first tap, scroll or key press (the specification: the algorithm terminates when scroll or input events occur).

    Illustration 1: the minimal case

    <h1>Spring boots</h1>
    <img src="/img/hero-1200.jpg" width="1200" height="600" alt="A row of leather boots">
    <p>Free returns on all orders.</p>

    The heading is painted first, so it is the first LCP candidate. When the hero image is painted and its visible area is larger than the heading's text rectangle, a new entry replaces it, and the reported LCP is the image's paint time. If the image fails to load, the heading stays the LCP element and the metric looks fast because the page is broken. The element can also differ by screen: on a narrow phone a long heading can push the hero down and a text block may win, so check field data. The API ignores elements inside iframes, though the metric counts them.

    The four parts, and how to read them

    The four parts add up to the LCP value when the element depends on a resource such as an image. For text, the middle two parts do not apply.

    Figure 2. The four parts on one timeline

    sequenceDiagram accTitle: The four parts of LCP for an image element accDescr: The browser requests the page, and the time until the first byte is TTFB. Then it must discover the image and start the request, which is the resource load delay. Downloading the image is the resource load duration. Finally the browser needs the main thread and styles ready to paint, the element render delay. participant B as Browser participant S as Server participant I as Image host B->>S: request the page (navigation start) Note over B,S: Part 1: time to first byte S-->>B: first byte of HTML Note over B: Part 2: resource load delay (find the image, start the request) B->>I: request the image Note over B,I: Part 3: resource load duration I-->>B: image bytes Note over B: Part 4: element render delay (needs the main thread, ready styles) Note over B: image painted: LCP

    How to read this: nothing in parts 2 to 4 can start before part 1 ends, and part 3 cannot start before part 2 ends. A late start is a different problem from a slow download.

    Web.dev gives rough ideal proportions: about 40 percent TTFB, under 10 percent load delay, about 40 percent load duration and under 10 percent render delay, as guidelines, not absolute time targets. They show an LCP out of balance: a load delay of a third of the total is the first place to look, though web.dev adds that if LCP is consistently within 2.5 seconds the proportions matter little.

    An illustrative breakdown (invented numbers) of a poor LCP on the landing page:

    Part Time Share of 3.6 s Reading
    TTFB 0.9 s 25% Below the roughly 40% guide, so not the main problem
    Resource load delay 1.2 s 33% Far above the under-10-percent guide: the image is found late
    Resource load duration 1.0 s 28% Below the guide, so the file is not the main problem
    Element render delay 0.5 s 14% Slightly high: something holds back the paint

    The largest anomaly is the load delay, so that is where to start; shrinking the image first would help less.

    Where to get the parts: the Chrome DevTools Performance panel (live metrics, and Insights with an LCP breakdown); PageSpeed Insights for CrUX field data and a Lighthouse run; and, for your own users, the web-vitals/attribution build, whose LCPAttribution object carries target, url, timeToFirstByte, resourceLoadDelay, resourceLoadDuration and elementRenderDelay (the README's examples use web-vitals@5 at the time of writing; the middle two are 0 for text). Send them to your own collector (see the Core Web Vitals lesson) and aggregate per page template.

    Fixing the load delay: make the resource discoverable and urgent

    The browser's preload scanner looks ahead through the HTML as it arrives and starts requests for what it finds, so an <img> in the HTML is found at once. A CSS background image is not discoverable from the HTML, nor is an image URL that exists only after JavaScript or an API call.

    Figure 3. Discoverable in the HTML, or hidden behind script

    sequenceDiagram accTitle: Image discovery with and without the preload scanner accDescr: When the hero image is in the HTML, the browser requests it right after the first bytes arrive. When its URL is created by script, the browser must first download and run the script, then fetch data, and only then request the image. participant B as Browser participant C as CDN participant A as API B->>C: request the page C-->>B: HTML alt hero img is in the HTML B->>C: request hero image (found by the scanner) else hero URL is created by JavaScript B->>C: request the JavaScript bundle C-->>B: bundle Note over B: run the script B->>A: request banner data A-->>B: JSON with the image URL B->>C: request hero image end C-->>B: image bytes

    How to read this: the second branch adds two network round trips (the bundle and the data) and a script run before the image request even starts. All of that time is load delay.

    The fixes, in the order to try them:

    1. Put the image in the HTML as an <img> (server-rendered or static). This is an architecture decision: see Rendering Strategies.
    2. Raise the priority with fetchpriority="high" on the likely LCP image. Web.dev's page on Fetch Priority says images start at low priority and are boosted to high at layout time if they are within the viewport, and that as of Chrome 117 the first five large images are set to medium. The attribute lets the important image start at high at once. It is a hint, not a directive; use it on one or two images, because if everything is high, nothing is. That page lists support from Chrome 102, Edge 102, Firefox 132 and Safari 17.2 at the time of writing.
    3. Never lazy-load the LCP image. Web.dev says lazy-loading the LCP image "will always lead to unnecessary resource load delay". In web.dev's lazy-loading experiment (a demo WordPress site, WebPageTest, medians of nine runs) the desktop archive page had an LCP median of 2,029 ms with default lazy loading and 1,759 ms with it disabled, while the single-post page differed little (1,655 ms against 1,726 ms). One lab experiment: the direction, not a size to expect.
    4. Use rel="preload" when the image cannot be in the HTML, for example a CSS background image or an image chosen by script. Web.dev's preload article says preload, unlike preconnect, "is mandatory for the browser", and an unused one triggers a Chrome console warning about 3 seconds after load. For responsive images use imagesrcset and imagesizes on the <link>, which take the same syntax as srcset and sizes:
    <link rel="preload" as="image" href="hero-800.avif"
          imagesrcset="hero-400.avif 400w, hero-800.avif 800w, hero-1600.avif 1600w"
          imagesizes="100vw" fetchpriority="high">

    Web.dev's caveats (page updated 10 July 2026): responsive preloads may not work in a Link HTTP header, cannot be used in 103 Early Hints, have no equivalent for <picture>, and should be avoided for an image that is quickly discoverable in the HTML. 5. Send 103 Early Hints when the server needs time to build the page. Chrome's documentation (updated 10 July 2026) describes a preliminary response for navigation requests that supports preconnect and preload (not prefetch); only cacheable resources can be preloaded this way, or they are fetched twice. Listed support: Chrome and Edge 103, Firefox 120 (preconnect) and 123 (preload), Safari 17 (preconnect only). When the server can answer at once, a tag in the HTML is enough.

    Fixing the load duration: fewer, closer bytes

    The guide notes this part is often not the main bottleneck, so check field data first. Its list: an image sized for its slot, AVIF or WebP with compression, the same origin as the HTML (preconnect reduces the cost of another origin, but same-origin is better), a CDN, a long cache policy for repeat visits, and fetchpriority="low" for images out of view, such as hidden carousel slides. See HTTP Caching for the Front End and CDN and Object Storage.

    Fixing the render delay: the image arrived but is not painted

    Render delay is the gap between the resource finishing and the element appearing. Web.dev's guide names a stylesheet that takes longer to load than the LCP image, synchronous scripts in the <head> ("almost never necessary"), an element hidden until JavaScript reveals it (its example is an A/B testing library), an element not yet in the DOM because it waits for JavaScript (the remedy is server-side rendering), and long tasks, because images render on the main thread. Remedies: inline CSS only if it is small, avoid synchronous head scripts, render the element on the server, break up long tasks (see The Event Loop and the Main Thread and The Critical Rendering Path). For a text LCP element, fonts matter: text in the font block period is invisible and cannot be a candidate, so use a font-display value other than auto or block.

    Fixing the first byte

    The guide's advice: minimise redirects, avoid unique URL parameters (analytics ones, say) that stop a CDN serving cached content, and use a CDN. Server-side rendering adds processing time that can slow TTFB, which the guide still considers worthwhile; build-time HTML is generally better where the architecture allows it.

    Choosing fixes: trade-offs

    Fix Part it moves Cost or risk
    Hero <img> in server-rendered HTML Load delay, render delay Rendering infrastructure; hydration cost
    fetchpriority="high" Load delay, duration (less contention) A hint; overuse removes its value
    rel="preload" Load delay Mandatory fetch: a wasted download if the markup changes; responsive and <picture> caveats
    103 Early Hints Load delay, TTFB overlap Server and CDN support; a non-cacheable resource is fetched twice; no responsive preloads
    Smaller formats and sizes Load duration Build step; quality review
    Same-origin or preconnected hosting Load duration Hosting and cache changes
    Defer or inline CSS and scripts Render delay Inlining too much grows every HTML response
    HTML at the edge TTFB Invalidation and stale content

    Illustration 2: the landing page, diagnosed

    The landing page's hero is injected by a slider library: the HTML holds an empty container, and a script reads banner JSON and sets a CSS background-image. The 3.6 s breakdown says "load delay", and Figure 3's second branch shows why. The fix is an <img> in the HTML, not a smaller file.

    Illustration 3: when LCP is fast for the wrong reason

    A site shows a full-width splash image for the first second, then removes it. Because removed content is still considered, the early-painted splash can become the LCP element and stay it unless a larger element renders. The reported LCP is the splash's time, which looks excellent while the real content arrives much later. The W3C draft lists this under known limitations (removed content is kept for image carousels, at the cost of problems for splash screens with large placeholders); pages loaded off-screen, such as in a background tab, report larger values than expected. When a number looks too good, collect target and url with the time.

    In practice: the weekly hero banner

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common patterns, and every number in it is an illustrative assumption.

    The situation

    The store's category landing page uses a headless CMS, and marketing swaps the hero banner each week. Field LCP at p75 on mobile is 3.6 s (illustrative), poor under the 2.5 s target. RUM with attribution shows the largest part is resource load delay.

    Starting architecture

    Figure 4. Starting architecture: the hero is created by script

    Starting architecture: hero image is created by a slider scriptBrowser to Page HTML (1. request); Browser to Slider bundle (2. bundle); Browser to CMS API (3. banner JSON); Browser to Image storage (4. hero image)Browser: finds the hero lateBrowserfinds the herolatePage HTML: empty hero containerPage HTMLempty herocontainerSlider bundle: sets a CSS backgroundSlider bundlesets a CSSbackgroundCMS API: banner JSON, uncachedCMS APIbanner JSON,uncachedImage storage: original uploadImage storageoriginal upload1. request2. bundle3. banner JSON4. hero image

    How to read this: the numbers are the order of requests. The image, the thing that is the LCP element, is requested last.

    Options considered

    Option Helps Costs and risk
    Compress the hero harder Load duration Cheap, but not the largest part
    Add a preload for the current banner Load delay The URL changes weekly: the preload must be generated with the page, or it fetches a stale image
    Render the banner in the HTML on the server, with fetchpriority="high" Load delay, render delay Server work; the CMS API must be reachable at render time; cache invalidation on the weekly swap

    The decision

    Field Decision record
    Context Field data shows the load delay is the largest part of LCP on the landing template.
    Decision Server-render the hero as an <img> with width, height, srcset, sizes and fetchpriority="high"; never lazy-load it. Serve it through an image pipeline with AVIF or WebP and a fallback. Cache the HTML for a short time and invalidate on publish.
    Consequences, good The image request starts as soon as the first HTML bytes are parsed.
    Consequences, bad More rendering work at the origin and a cache to purge; the slider's behaviour must work without the script's first paint.
    Revisit when Field p75 LCP is not under 2.5 s two weeks after release, or the largest part moves to TTFB.

    Figure 5. Target architecture: the hero is in the HTML and served from the edge

    Target architecture: hero image discoverable in server-rendered HTMLBrowser to CDN edge (page); CDN edge to Renderer (on a miss); Renderer to CMS API (banner data); Browser to Image pipeline (hero image)Browser: scanner finds the heroBrowserscanner finds theheroCDN edge: short-lived HTML cacheCDN edgeshort-lived HTMLcacheRenderer: hero img in the HTMLRendererhero img in theHTMLImage pipeline: sizes, AVIF or WebPImage pipelinesizes, AVIF orWebPCMS API: banner dataCMS APIbanner datapageon a missbanner datahero image

    How to read this: the renderer reads the banner from the CMS while building the HTML, so the browser never has to ask for it. The image request leaves right after the first parse.

    Rollout and verification

    1. Ship attribution reporting first, tagged by template and release, and record a baseline for each part.
    2. Release to a share of traffic and compare the parts, not only the total: load delay should fall while TTFB does not grow enough to cancel the gain.
    3. Add a CI check that fails if the hero <img> has loading="lazy" or lacks dimensions.

    The 2 a.m. failure

    At 02:00 a scheduled weekly banner goes live. The editor uploaded a large unprocessed file that bypassed the image pipeline. The RUM alert on LCP p75 for the landing template fires, and the attribution shows load duration, not load delay, as the largest part. First checks: the image URL in the attribution data, its size in the CDN logs and whether the pipeline ran. Mitigation: replace the banner with the processed version, then purge the cached HTML. Follow-up: make the CMS refuse oversized uploads and generate the responsive set on upload.

    Architect's notes

    • Decide per template which element should be the LCP element, who owns it, and where the browser discovers it. Write it in the page spec.
    • Treat the hero's markup as a contract that CI can check, and prefer discoverability over workarounds: preload and Early Hints repair a late discovery; an <img> in the HTML removes it.
    • Measure parts, not just the total, keep the LCP element in the field data, and distrust good numbers from splash screens, background tabs and broken images.

    Common mistakes

    • Optimizing the wrong part, such as compressing images when the load delay is largest.
    • Lazy-loading the hero, marking every image fetchpriority="high", or hiding the hero behind client-side rendering, an entrance animation or a render-blocking script.
    • Preloading a URL that changes without generating the preload from the same data as the markup.
    • Assuming the LCP element is the same on every device, or never recording which element it was.

    Key takeaways

    • LCP is a running maximum over eligible, painted elements until the first interaction; removed elements still count.
    • Find the element, then the largest of the four parts, then fix that part.
    • Load delay is the cheapest win: an <img> in the HTML beats any workaround.
    • fetchpriority is a hint for one or two images; preload is a mandatory fetch and needs care. Never lazy-load the LCP image.
    • Render delay comes from blocking CSS and scripts, hidden elements and long tasks.
    • Check that a good LCP describes real content, and keep the element and its parts in field data so alerts tell you what to fix.

    Practice exercises

    Exercise 1 — Starter (30 min): In Chrome DevTools find a page's LCP element at desktop and phone window sizes. Are they the same? Which of the four parts looks largest?

    Exercise 2 — Intermediate (2 hours): Build a page whose hero is a CSS background-image. Record the four parts, then move it into the HTML as an <img> with fetchpriority="high" and compare.

    Exercise 3 — Intermediate (2 hours): Log target, url and the part durations with web-vitals/attribution. Add loading="lazy" to the hero on purpose and see which part grows.

    Exercise 4 — Advanced (half day): Write the CI check from the rollout: fail a build when the hero <img> lacks width and height or uses loading="lazy", or when more than two images carry fetchpriority="high". What can the check not see?

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments