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
-
Measure the compressed HTML payload against the route ceiling.
curl -s --compressed https://your-storefront.com/category/shoes | wc -cExpected output: a byte count under the route HTML ceiling (for example
9800for a PLP capped at 10 KB). -
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
IMGelement and arenderTimewithin the route ms ceiling. -
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="[^"]*"[^>]*>' | headExpected output: the hero
<img>carriesfetchpriority="high"and noloading="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 gate —
npx lhci autorun --collect.numberOfRuns=3reportslargest-contentful-paintunder 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.