diff --git a/CHANGELOG.md b/CHANGELOG.md index 39869a78..f66bc473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### v5.0.0-rc.1 (???) + +- **[BREAKING]** Add interoperable TTFB to measure Early Hints consistently ([#566](https://github.com/GoogleChrome/web-vitals/pull/566)) + ### v5.0.0-rc.0 (2024-10-03) - **[BREAKING]** Remove the deprecated `onFID()` function ([#519](https://github.com/GoogleChrome/web-vitals/pull/519)) diff --git a/src/attribution/onLCP.ts b/src/attribution/onLCP.ts index 3f91d68e..352a0b15 100644 --- a/src/attribution/onLCP.ts +++ b/src/attribution/onLCP.ts @@ -45,7 +45,16 @@ const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => { .getEntriesByType('resource') .filter((e) => e.name === lcpEntry.url)[0]; - const ttfb = Math.max(0, navigationEntry.responseStart - activationStart); + const ttfb = Math.max( + 0, + // From Chrome 115 until 133, Chrome reported responseStart as the + // document bytes, rather than Early Hint bytes. Prefer the Early Hint + // bytes (firstInterimResponseStart) for consistency with other + // browers, but only if non-zero (so use || rather than ??) as zero + // indicates no early hints. + (navigationEntry.firstInterimResponseStart || + navigationEntry.responseStart) - activationStart, + ); const lcpRequestStart = Math.max( ttfb, diff --git a/src/onTTFB.ts b/src/onTTFB.ts index 721dca0e..9a87febf 100644 --- a/src/onTTFB.ts +++ b/src/onTTFB.ts @@ -71,14 +71,20 @@ export const onTTFB = ( const navigationEntry = getNavigationEntry(); if (navigationEntry) { + // From Chrome 115 until 133, Chrome reported responseStart as the + // document bytes, rather than Early Hint bytes. Prefer the Early Hint + // bytes (firstInterimResponseStart) for consistency with other + // browers, but only if non-zero (so use || rather than ??) as zero + // indicates no early hints. + const responseStart = + navigationEntry.firstInterimResponseStart || + navigationEntry.responseStart; + // The activationStart reference is used because TTFB should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the first byte is received, this time should be clamped at 0. - metric.value = Math.max( - navigationEntry.responseStart - getActivationStart(), - 0, - ); + metric.value = Math.max(responseStart - getActivationStart(), 0); metric.entries = [navigationEntry]; report(true); diff --git a/src/types.ts b/src/types.ts index 2f1e2214..2e21636d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,9 +53,12 @@ declare global { durationThreshold?: number; } - // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension interface PerformanceNavigationTiming { + // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension activationStart?: number; + // https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-firstinterimresponsestart + firstInterimResponseStart?: number; + finalResponseHeadersStart?: number; } // https://wicg.github.io/event-timing/#sec-performance-event-timing diff --git a/test/e2e/onTTFB-test.js b/test/e2e/onTTFB-test.js index 25453043..e1c56982 100644 --- a/test/e2e/onTTFB-test.js +++ b/test/e2e/onTTFB-test.js @@ -368,6 +368,44 @@ describe('onTTFB()', async function () { assert.strictEqual(ttfb.attribution.requestDuration, 0); assert.strictEqual(ttfb.attribution.navigationEntry, undefined); }); + + it('reports the correct value for Early Hints', async function () { + await navigateTo( + // '/test/ttfb?responseStart=10&earlyHintsDelay=50&attribution=1', + '/test/ttfb?earlyHintsDelay=50&attribution=1', + ); + + const ttfb = await getTTFBBeacon(); + + if ('finalResponseHeadersStart' in ttfb.attribution.navigationEntry) { + assert.strictEqual( + ttfb.value, + ttfb.attribution.navigationEntry.responseStart, + ); + assert.strictEqual( + ttfb.value, + ttfb.attribution.navigationEntry.firstInterimResponseStart, + ); + assert( + ttfb.value < + ttfb.attribution.navigationEntry.finalResponseHeadersStart, + ); + } else if ( + 'firstInterimResponseStart' in ttfb.attribution.navigationEntry + ) { + // TODO: Can remove these after Chrome 133 lands and above is used. + assert(ttfb.value < ttfb.attribution.navigationEntry.responseStart); + assert.strictEqual( + ttfb.value, + ttfb.attribution.navigationEntry.firstInterimResponseStart, + ); + } else { + assert.strictEqual( + ttfb.value, + ttfb.attribution.navigationEntry.responseStart, + ); + } + }); }); }); diff --git a/test/server.js b/test/server.js index ff666576..5d13d4b0 100644 --- a/test/server.js +++ b/test/server.js @@ -39,6 +39,19 @@ app.use((req, res, next) => { } }); +// Allow the use of a `earlyHintsDelay` query param to delay any response +// after sending an early hints +app.use((req, res, next) => { + if (req.query && req.query.earlyHintsDelay) { + res.writeEarlyHints({ + 'link': '; rel=preload; as=style', + }); + setTimeout(next, req.query.earlyHintsDelay); + } else { + next(); + } +}); + // Add a "collect" endpoint to simulate analytics beacons. app.post('/collect', bodyParser.text(), (req, res) => { // Uncomment to log the metric when manually testing.