LCP attribution can report a negative `resourceLoadDuration`
attribution.resourceLoadDuration can be reported as a negative number, with resourceLoadDelay larger than LCP itself and elementRenderDelay flattened to 0.
attributeLCP() runs at report time (first trusted click/keydown/visibilitychange), not at LCP time, so it can see Resource Timing entries created after the LCP render. It resolves the LCP resource at src/attribution/onLCP.ts:114-119:
const lcpResourceEntry =
lcpEntry.url &&
(resourceBuffer.findLast((e) => e.name === lcpEntry.url) ||
performance.getEntriesByType('resource').findLast((e) => e.name === lcpEntry.url));There is no constraint that the matched entry predates the paint. If the LCP URL is requested again after LCP but before the metric is finalized — a fetch()/new Image() prefetch, a carousel or lazy-load library re-inserting the same src, the hero image reused as a CSS background, a service-worker revalidation — findLast deliberately returns that later entry.
The subpart maths then produces the negative, because lcpResponseEnd is capped at metric.value (line 163) but lcpRequestStart (line 155) is not:
lcpRequestStart=max(ttfb, requestStart - activationStart)— uncapped, so it can exceedmetric.valuelcpResponseEnd=min(metric.value, ...)— collapses tometric.valueresourceLoadDuration=lcpResponseEnd - lcpRequestStart→ negativeresourceLoadDelay=lcpRequestStart - ttfb→ larger than LCP
Both halves are individually reasonable. The Math.min cap came from #527 (fixing #439), so media that keeps downloading past LCP doesn't overstate the duration. findLast came from #776, so a later request wins over an earlier stale one, notably on soft navs. They only interact badly once the matched entry is allowed to start after the paint.
Not a duplicate of #439 — that was onTTFB discarding untrustworthy navigation data, and it predates this mechanism by two years. The symptom overlaps (resourceLoadDelay > LCP); the cause does not.
Impact
Negative and nonsensical LCP subparts reaching analytics, plus attribution.lcpResourceEntry pointing at a resource that had nothing to do with the paint — so initiatorType, requestStart and responseEnd are all attributed to the wrong request. Re-requesting the LCP image before the first user interaction is common on image-heavy and SPA pages.
The four subparts still sum to metric.value, so this doesn't surface as an internal inconsistency — only as a negative value.
Steps to reproduce
Navigation responseStart = 100 (TTFB 100, activationStart 0). hero.jpg requested at requestStart = 210, responseEnd = 800. LCP renders at startTime = 1000. The same URL is re-requested at requestStart = 1510, responseEnd = 1600. A trusted click then finalizes LCP.
I drove the shipped dist/modules/attribution/onLCP.js with those entries and stubbed browser globals, with performance.getEntriesByType('resource') returning [] so only the local buffer is consulted (the #775 scenario). The entry values are synthetic; all lookup and attribution logic is the real built code.
Expected — and what you get without the second request:
lcpResourceEntry : initiatorType=img requestStart=210 responseEnd=800
timeToFirstByte : 100
resourceLoadDelay : 110
resourceLoadDuration : 590
elementRenderDelay : 200Actual (v6.2.1):
lcpResourceEntry : initiatorType=fetch requestStart=1510 responseEnd=1600 <-- the post-LCP request
timeToFirstByte : 100
resourceLoadDelay : 1410 <-- larger than LCP (1000)
resourceLoadDuration : -510 <-- negative
elementRenderDelay : 0Suggested fix
Constrain candidates to resources requested at or before the LCP render, then keep findLast among those:
// The LCP resource must have been requested at or before the LCP render;
// a later request for the same URL cannot be what painted the element.
// `responseEnd` is deliberately not checked, so media that keeps
// downloading past LCP still matches.
const isLCPResource = (e) =>
e.name === lcpEntry.url &&
(e.requestStart || e.startTime) <= lcpEntry.startTime;
const lcpResourceEntry =
lcpEntry.url &&
(resourceBuffer.findLast(isLCPResource) ||
performance.getEntriesByType('resource').findLast(isLCPResource));Comparing on the raw performance timeline (not activation-adjusted) keeps this correct for prerender and soft navs.
I ran this against a patched copy of the built module in the cases the current behaviour exists for:
| Scenario | v6.2.1 | Patched |
|---|---|---|
| URL re-requested after LCP | wrong entry, -510 |
correct entry, 590 |
| No second request | 110 / 590 / 200 |
identical |
| Media still downloading past LCP (#527) | 110 / 790 / 0 |
identical |
| Same URL twice, both pre-paint (#776) | picks the later | identical |
So the Math.min cap and the findLast preference both keep working.
One tradeoff for you to decide
If the pre-paint entry has been evicted from both the local buffer and the browser's 250-entry buffer, and only the post-LCP re-request remains, the filter finds nothing and lcpResourceEntry becomes undefined. The subparts stay sane (timeToFirstByte: 100, resourceLoadDelay: 0, resourceLoadDuration: 0, elementRenderDelay: 900), but that does work against #775/#776, which exist precisely to stop losing that entry.
If you'd rather never lose it, the alternative is to prefer a pre-paint match, fall back to any match, and additionally cap lcpRequestStart at metric.value so the fallback can't go negative. I tried capping lcpRequestStart on its own: it removes the negative (resourceLoadDelay: 900, resourceLoadDuration: 0) but leaves the wrong entry selected and the subparts still fictional — so on its own it masks the symptom rather than fixing the selection. Happy to implement whichever you prefer.
Why this went unnoticed
The only e2e test touching this area, supports configuring a larger resource buffer size (test/e2e/onLCP-test.js:1448-1452), doesn't currently assert anything — its stub is commented out and replaced by a bare expression statement:
// Stub performance.getEntriesByType to return []
await browser.execute(() => {
// performance.getEntriesByType = () => [];
performance.setResourceTimingBufferSize;
});With the stub disabled, attribution falls through to performance.getEntriesByType('resource'), which always satisfies the assertion. The full --metrics=LCP suite passes (40 passing, 2 skipped) both with and without the fix above. I'll will restore that stub in the same PR, or file it separately if youd prefer.
Will open a PR once you confirm with regression tests.
Environment: web-vitals 6.2.1 (regression introduced in 6.1.0), attribution build. Verified against a clean rebuild of main at 582ee74.
Source: GoogleChrome/web-vitals