How to Set Realistic LCP Budgets for E-commerce

A single 2.5 s Largest Contentful Paint (LCP) target fails for storefronts because a product listing page, a product detail page, and a checkout flow have fundamentally different constraints. Listing pages are image-heavy, detail pages carry galleries and reviews, and checkout must strip third-party scripts to protect conversion. This guide is part of the Core Web Vitals Budget Allocation reference, and it sets per-route LCP budgets with exact byte and millisecond breakdowns, a hero-image preload implementation, and a route-aware Lighthouse CI gate.

Per-Page-Type LCP Budget Breakdown

Budgets must be enforced at the route level, with each page type given a byte ceiling per resource and an overall LCP ms ceiling for mid-range mobile over 4G. The table below is the route contract; checkout is the tightest because every blocking byte there costs conversion directly.

Route type LCP P75 (mobile) HTML Critical CSS LCP image Web fonts 3rd-party on LCP path
PLP (listing) ≤ 2000 ms ≤ 10 KB ≤ 15 KB ≤ 150 KB (AVIF) ≤ 50 KB 0 KB
PDP (detail) ≤ 2200 ms ≤ 12 KB ≤ 18 KB ≤ 150 KB (AVIF) ≤ 50 KB 0 KB
Checkout ≤ 1500 ms ≤ 8 KB ≤ 20 KB ≤ 80 KB ≤ 30 KB 0 KB

LCP is the sum of network latency, resource download, and main-thread render delay, so each ceiling is a discrete byte budget rather than one aggregate. Inline personalization or geo-pricing scripts exceeding 5 KB will breach the mobile budget and must be deferred to post-paint or rendered server-side at the edge.

These per-route numbers are starting points; derive your own from field P75 segmented by route and device class, then set the lab assertion 10–15% tighter to absorb the lab-to-field gap. A grace band is reasonable on slow connections: a checkout-only route on Slow 4G can carry a 3200 ms ceiling without weakening the high-end mobile contract, because the two are gated as separate device classes rather than one blended average. Tag RUM events by window.location.pathname so each route's distribution is measured independently and a regression on one page type cannot be masked by headroom on another.

Diagnostic Steps

  1. Measure the compressed HTML payload against the route ceiling.

    curl -s --compressed https://your-storefront.com/category/shoes | wc -c

    Expected output: a byte count under the route HTML ceiling (for example 9800 for a PLP capped at 10 KB).

  2. Identify the LCP element and its load time in the browser console to confirm the right asset is being preloaded.

    const lcp = performance.getEntriesByType('largest-contentful-paint').at(-1);
    console.log({ element: lcp?.element?.tagName, renderTime: Math.round(lcp?.renderTime) });

    Expected output: the hero IMG element and a renderTime within the route ms ceiling.

  3. Audit for accidental lazy-loading on the LCP image, which silently inflates LCP.

    curl -s https://your-storefront.com/category/shoes \
      | grep -oE '<img[^>]*fetchpriority="[^"]*"[^>]*>' | head

    Expected output: the hero <img> carries fetchpriority="high" and no loading="lazy" attribute.

Implementation

Preload the LCP image and elevate it above the default network queue with fetchpriority="high", keep explicit width/height to prevent layout shift from delaying paint, and use font-display: optional for LCP text to avoid swap delays. Below-fold galleries use IntersectionObserver exclusively so they never compete with the hero.

<!-- In <head>: preload the hero so it leads the network queue -->
<link rel="preload" as="image"
  href="/cdn/hero-1200.avif" fetchpriority="high"
  imagesrcset="/cdn/hero-800.avif 800w, /cdn/hero-1200.avif 1200w">

<!-- The LCP element: high priority, dimensioned, never lazy -->
<img src="/cdn/hero-1200.avif"
  srcset="/cdn/hero-800.avif 800w, /cdn/hero-1200.avif 1200w"
  sizes="(max-width: 768px) 100vw, 1200px"
  width="1200" height="600" fetchpriority="high" alt="Featured product">

<!-- Defer non-critical theme JS so it never blocks the LCP paint -->
<script src="/assets/theme.js" defer></script>

Gate any analytics, chat, or A/B-test script behind requestIdleCallback so it cannot race the critical window, and keep the checkout route free of third-party scripts entirely.

CI Gating Assertion

Run separate mobile and desktop jobs so an environment-specific regression cannot hide. This lighthouserc.js enforces the route LCP ceiling and warns on render-blocking resources.

// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: [
        'https://staging.example.com/category/shoes',
        'https://staging.example.com/checkout'
      ],
      numberOfRuns: 3,
      settings: { preset: 'mobile', throttlingMethod: 'simulate' }
    },
    assert: {
      assertions: {
        'largest-contentful-paint': ['error', { maxNumericValue: 2400 }],
        'render-blocking-resources': ['warn', { maxLength: 1 }],
        'uses-responsive-images': ['warn', {}]
      }
    }
  }
};

For per-route ceilings (a 1500 ms checkout versus a 2000 ms PLP), run one Lighthouse CI job per route with its own maxNumericValue, and require each as a separate status check in branch protection.

Verification

Confirm the route budgets hold before merge:

  • Synthetic gatenpx lhci autorun --collect.numberOfRuns=3 reports largest-contentful-paint under the route ceiling on the mobile preset, averaged across three runs.
  • LCP element — the Lighthouse "Largest Contentful Paint element" audit names the hero image, confirming it is preloaded rather than lazy-loaded.
  • Field correlation — RUM P75 LCP per route, segmented by window.location.pathname, stays within 10% of the synthetic median; a wider gap signals infrastructure drift, not a code regression.
  • Pass rate — LCP passes on at least 95% of runs before the merge is allowed; a hotfix bypass requires a director-approved override ticket.

Frequently Asked Questions

Why give checkout a tighter LCP budget than the listing page?

Checkout is the conversion-critical surface where every blocking byte costs revenue, and it carries no third-party scripts, so a 1500 ms ceiling is both achievable and worth enforcing. A product listing page is image-heavy by nature and gets a 2000 ms ceiling with aggressive hero preloading instead. Per-route budgets follow the allocation method in Core Web Vitals Budget Allocation.

Does lazy-loading ever apply to the LCP image?

No. Applying loading="lazy" or fetchpriority="low" to the LCP element defers its fetch and inflates LCP. Remove lazy attributes from above-the-fold imagery and reserve IntersectionObserver for below-fold galleries only.

How do I stop personalization scripts from breaching the budget?

Render geo-pricing and A/B variants server-side at the edge, or defer any inline personalization script over 5 KB to post-paint behind requestIdleCallback. Keep the LCP path free of synchronous third-party execution.