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.
Every threshold below is expressed as a P75 target on mid-range mobile hardware (roughly a Moto G-class device) over an emulated Fast 3G / 4x-CPU profile, because that is where storefront traffic actually lives and where a blended average would hide the tail. Where a route needs a looser ceiling on a genuinely slower connection, it is gated as a separate device class rather than folded into one number.
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. The table below is the route contract; checkout is the tightest because every blocking byte there costs conversion directly. Treat the byte columns as a hard sum: the LCP path can only spend what fits inside the ms ceiling once network round trips are accounted for.
| 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. The PDP gets a slightly higher 2200 ms ceiling because its LCP element is frequently a zoomable gallery frame that carries more decode work than a flat listing thumbnail; the checkout gets the tightest 1500 ms because it renders no marketing imagery and no vendor tags at all.
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 P75 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. If you are unsure whether to gate on the 75th or a stricter percentile, the trade-off is worked through in Choosing Between P75 and P90 Budget Targets.
How LCP Time Actually Splits
A byte budget is only meaningful if you know which phase of LCP each byte lands in. LCP decomposes into four sub-intervals: time to first byte (TTFB), resource load delay (how long after TTFB the browser discovers and starts fetching the LCP resource), resource load time (the download itself), and element render delay (main-thread work before the pixels paint). A hero image that is only 80 KB can still blow a 1500 ms checkout budget if it is discovered late, because the load-delay slice dwarfs the download slice. Preload attacks load delay; AVIF compression attacks load time; deferring scripts attacks render delay.
The practical consequence is that your byte ceilings must be paired with a delivery order. A 150 KB AVIF hero on a PLP is fine when it is preloaded and leads the fetch queue, but the identical file discovered after render-blocking CSS and a font will paint hundreds of milliseconds late. The compression story for those bytes is covered in Setting Responsive Image Byte Budgets, which is where the 150 KB and 80 KB ceilings above come from.
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. -
Confirm the hero leads the network waterfall by breaking LCP into its sub-parts in the field, not just the aggregate.
new PerformanceObserver((list) => { const e = list.getEntries().at(-1); const nav = performance.getEntriesByType('navigation')[0]; console.log({ ttfb: Math.round(nav.responseStart), loadDelay: Math.round(e.startTime - nav.responseStart) }); }).observe({ type: 'largest-contentful-paint', buffered: true });Expected output: a
loadDelayunder ~200 ms, confirming the hero is discovered early rather than after CSS and fonts.
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. The diagram below shows what that preload buys you: without it, the hero is discovered only after CSS, JS, and the font, and it paints late; with it, the hero is fetched in the first wave.
The same reordering logic applies to soft navigations in a single-page storefront, where the LCP element repaints on a client-side route change and the preload has to be issued programmatically; that case is handled in Measuring Client-Side Transition LCP.
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', {}]
}
}
}
};
The single maxNumericValue: 2400 above is a floor that every route must clear; it is not the real per-route contract. For per-route ceilings (a 1500 ms checkout P75 versus a 2000 ms PLP P75 on mobile), run one Lighthouse CI job per route with its own maxNumericValue, and require each as a separate status check in branch protection. Because these jobs feed a merge gate, keep them on stable, dedicated runners; the mobile and desktop split itself is worked through in Separate Mobile and Desktop Lighthouse Budgets.
# .github/workflows/lcp-gate.yml
name: lcp-gate
on: [pull_request]
jobs:
checkout-lcp:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Lighthouse CI on checkout route
run: |
npm install -g @lhci/[email protected]
lhci autorun \
--collect.url=https://staging.example.com/checkout \
--collect.numberOfRuns=5 \
--assert.assertions.largest-contentful-paint='["error",{"maxNumericValue":1500}]'
Five runs on the checkout job is deliberate: LCP is noisy on simulated throttling, and a three-run median can flap around a 1500 ms edge. Wiring this into a PR check end to end is covered in Running Lighthouse CI on Every Pull Request.
Verification
Confirm the route budgets hold before merge:
- Synthetic gate —
npx lhci autorun --collect.numberOfRuns=5reportslargest-contentful-paintunder the route ceiling on the mobile preset, taken as the median across five runs rather than the mean, so one slow cold-start run does not sink an otherwise-green PR. - 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 P75 ceiling on mid-range mobile is both achievable and worth enforcing. A product listing page is image-heavy by nature and gets a 2000 ms P75 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 so it never lands in the render-delay slice of LCP.
My hero is only 80 KB but LCP is still 2600 ms — why?
Byte size only governs the download slice. If the hero is discovered after render-blocking CSS and a web font, the load-delay slice dominates and paint lands late regardless of file size. Add a <link rel="preload" as="image" fetchpriority="high"> hint so the browser starts the fetch in the first wave instead of after the CSS finishes parsing.
Which percentile and device should the gate assert on?
Assert on the P75 of your mid-range mobile field data (a Moto G-class device on Fast 3G / 4x CPU), because that is what Core Web Vitals scores and where the tail is. Set the lab ceiling 10-15% tighter than field P75 to absorb the lab-to-field gap, and gate slower connections as a separate device class rather than blending them into one average.