LCP attribution can report a negative `resourceLoadDuration`

Author: suhailopensourceCreated Aug 27, 2026Updated Sep 15, 2026

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:

javascript
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 exceed metric.value
  • lcpResponseEnd = min(metric.value, ...) — collapses to metric.value
  • resourceLoadDuration = lcpResponseEnd - lcpRequestStartnegative
  • resourceLoadDelay = lcpRequestStart - ttfblarger 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   : 200

Actual (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   : 0

Suggested fix

Constrain candidates to resources requested at or before the LCP render, then keep findLast among those:

javascript
// 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:

javascript
// 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.