Attributing LCP Regressions to a Specific Chunk
The alert is unambiguous and useless: Largest Contentful Paint at the 75th percentile moved from 2.1 s to 3.4 s on the product route, crossing the 2.5 s threshold. Fourteen pull requests shipped in that release. Nothing in CI failed, because the total bundle grew by 11 KB, well inside the budget.
A page-level metric cannot tell you which of those fourteen changes caused the regression. The work is turning “LCP regressed” into “the product-gallery chunk grew by 90 KB and it is on the path to rendering the hero image” — a statement specific enough to act on. This page is that procedure, built on the field instrumentation described in measuring real-user chunk loading performance.
Root cause: LCP has four phases and only two of them are about the image
Treating LCP as one number is what makes it unattributable. It decomposes into four consecutive phases, and JavaScript dominates two of them:
- Time to first byte — server and network before the document arrives.
- Resource load delay — the gap between first byte and the LCP resource’s request starting. In a client-rendered application, this is where chunk downloading and evaluation lives, because the element does not exist until code creates it.
- Resource load duration — the LCP resource’s own download.
- Render delay — the gap between the resource arriving and the element painting. Hydration work lands here.
A chunk regression almost always shows up in load delay or render delay. If neither moved, the cause is not JavaScript at all, and further chunk analysis is wasted effort.
Collecting the attribution
The web-vitals attribution build reports the phase breakdown directly, and adding the in-flight chunk set to each report is what makes it actionable.
// lcp-attribution.js — report the phase breakdown plus the chunks in each phase
import { onLCP } from 'web-vitals/attribution';
function scriptsBetween(start, end) {
return performance.getEntriesByType('resource')
.filter((e) => e.initiatorType === 'script' &&
e.startTime < end && e.startTime + e.duration > start)
.map((e) => ({
file: e.name.slice(e.name.lastIndexOf('/') + 1),
ms: Math.round(e.duration),
cached: e.transferSize === 0,
}));
}
onLCP(({ value, attribution }) => {
const { timeToFirstByte: ttfb, resourceLoadDelay, resourceLoadDuration, elementRenderDelay } = attribution;
report('lcp', {
value: Math.round(value),
phases: { ttfb, resourceLoadDelay, resourceLoadDuration, elementRenderDelay },
element: attribution.target,
// The scripts occupying the load-delay window are the prime suspects:
// this is the phase where chunk download and evaluation happens.
blockers: scriptsBetween(ttfb, ttfb + resourceLoadDelay),
}, { build: __BUILD_ID__ });
});Group the resulting reports by build id and by logical chunk name. A chunk that appears in the blockers list for the large majority of slow sessions, and did not appear before the release, is the regression.
Separating delivery cost from execution cost
Once a chunk is identified, one more split decides the fix. A chunk can be expensive because it takes a long time to arrive, or because it takes a long time to run once it has arrived. These need different remedies and are easily confused.
// chunk-cost.js — network time versus main-thread time, per chunk
const network = new Map();
for (const e of performance.getEntriesByType('resource')) {
if (e.initiatorType === 'script') network.set(e.name, Math.round(e.duration));
}
// Long tasks overlapping a script's evaluation window approximate its CPU cost.
const evaluation = new Map();
new PerformanceObserver((list) => {
for (const task of list.getEntries()) {
for (const [url, ] of network) {
const res = performance.getEntriesByName(url)[0];
if (!res) continue;
const evalStart = res.responseEnd;
if (task.startTime >= evalStart && task.startTime < evalStart + 500) {
evaluation.set(url, (evaluation.get(url) || 0) + Math.round(task.duration));
}
}
}
}).observe({ type: 'longtask', buffered: true });A chunk with 900 ms of network time and 40 ms of evaluation is a delivery problem: preload it, or move it off the critical path entirely with the component-level techniques in component-level code splitting beyond routes. A chunk with 200 ms of network time and 600 ms of evaluation is an execution problem, and preloading it changes nothing — it needs less code, or its work deferred past the paint.
Step-by-step verification
-
Confirm the phase. Before touching any chunk, verify that resource load delay or render delay actually grew. If time to first byte moved instead, the regression is server-side.
-
Confirm the chunk is on the path. The candidate must appear in the blocker set for a large majority of slow sessions, not a handful. A chunk present in 20% of them is a coincidence.
-
Diff the chunk against the previous release. Compare its module composition release over release using the attribution workflow in attributing bundle bloat with source maps to name the module that grew.
-
Test the fix in the lab first. Apply the change locally under throttling and confirm the phase you targeted shrinks. If it does not move in the lab, it will not move in the field.
-
Confirm in the field after release. Watch the same phase at the 75th percentile for at least 48 hours. Chunk-level changes take a full cache cycle to show their true effect.
-
Add a guard. Add the chunk’s size to the CI budget so the same growth fails a build next time, per failing builds on bundle size regressions.
Edge cases and gotchas
A different LCP element between releases. If a redesign changed which element is largest, the phase comparison is not measuring the same thing. Always group by the reported element selector before comparing.
Cache warming masks the regression. In the days after a release, returning users still hold the previous chunks. A regression that looks small on day one can grow as the cache turns over. Segment by cache status.
Server-rendered pages. With server rendering, the LCP element exists in the HTML, so load delay is usually short and chunk regressions surface in render delay through hydration instead — the mechanics in streaming SSR and selective hydration with code splitting apply directly.
Third-party scripts in the blocker set. An analytics or tag-manager script competing for bandwidth can extend load delay without any of your chunks growing. It appears in the same list and is easy to misread as your own code.
FAQ
How can a JavaScript chunk affect LCP when the LCP element is an image?
Two ways. A render-blocking script delays the point at which the browser can lay out the page at all, which pushes back when the image is even discovered. And in a client-rendered application the image element frequently does not exist in the initial HTML — it is created during hydration, so the image request cannot start until the chunk that renders it has downloaded and executed. In both cases the image is the LCP element and a chunk is the cause.
Which LCP phase does a chunk regression usually show up in?
Resource load delay, in a client-rendered application. That phase measures the gap between first byte and the LCP resource’s request starting, and it is exactly the window occupied by downloading and evaluating the chunks needed to render the element. A regression in resource load duration points at the resource itself rather than at JavaScript, and a regression in render delay points at main-thread work after the resource arrived — often hydration.
Should I preload the chunk or make it smaller?
Preload when the chunk is discovered late but is not itself large — the cost is queuing, not bytes, and starting the request earlier removes most of it. Make it smaller when the chunk’s own download or evaluation time dominates, because starting a 300 KB download earlier still leaves a 300 KB download on the critical path. The phase breakdown tells you which case you have: a large load delay with a short load duration is a discovery problem.
Related
- Measuring Real-User Chunk Loading Performance — the parent guide on collecting the field data this analysis reads
- Measuring INP Impact of Lazy Hydration — the interaction-side counterpart of this attribution
- Prefetch and Preload Strategies for Critical Routes — the fix when the phase that grew is load delay
- Attributing Bundle Bloat With Source Maps — naming the module inside the chunk that grew