Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
8 changes: 7 additions & 1 deletion src/attribution/onLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ export const onLCP = (

const ttfb = Math.max(
0,
navigationEntry.responseStart - activationStart,
// 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(
Expand Down
14 changes: 10 additions & 4 deletions src/onTTFB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions test/e2e/onTTFB-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,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,
);
}
});
});
});

Expand Down
13 changes: 13 additions & 0 deletions test/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '</styles.css>; 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.
Expand Down