Skip to content

Add interoperable TTFB to measure Early Hints consistently #566

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 14 commits into from
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- **[BREAKING]** Remove the deprecated `onFID()` function ([#519](https://github.com/GoogleChrome/web-vitals/pull/519))
- **[BREAKING]** Change browser support policy to Baseline Widely Available ([#525](https://github.com/GoogleChrome/web-vitals/pull/525))
- **[BREAKING]** Sort the classes that appear in attribution selectors to reduce cardinality ([#518](https://github.com/GoogleChrome/web-vitals/pull/518))
- **[BREAKING]** Add interoperable TTFB to measure Early Hints consistently ([#566](https://github.com/GoogleChrome/web-vitals/pull/566))
- Cap INP breakdowns to INP duration ([#528](https://github.com/GoogleChrome/web-vitals/pull/528))
- Cap LCP load duration to LCP time ([#527](https://github.com/GoogleChrome/web-vitals/pull/527))

Expand Down
10 changes: 9 additions & 1 deletion src/attribution/onLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,15 @@ 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, Chrome reported responseStart as the document
// bytes, rather than Early Hint bytes. Prefer the Early Hint bytes
// (firstInterimResponseStart) for consistency with other browers, if
// non-zero
navigationEntry.firstInterimResponseStart ||
navigationEntry.responseStart - activationStart,
);

const lcpRequestStart = Math.max(
ttfb,
Expand Down
13 changes: 9 additions & 4 deletions src/onTTFB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,19 @@ export const onTTFB = (
const navigationEntry = getNavigationEntry();

if (navigationEntry) {
// From Chrome 115 until, Chrome reported responseStart as the document
// bytes, rather than Early Hint bytes. Prefer the Early Hint bytes
// (firstInterimResponseStart) for consistency with other browers, if
// non-zero
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;
// Early Hints support
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 @@ -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,
);
}
});
});
});

Expand Down
43 changes: 42 additions & 1 deletion test/views/ttfb.njk
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,54 @@
<script>
// Set the blocking values based on query params if present.
const params = new URLSearchParams(location.search);
const navEntry = performance.getEntriesByType('navigation')[0];

if (params.has('responseStart')) {
const navEntry = performance.getEntriesByType('navigation')[0];
Object.defineProperty(navEntry, 'responseStart', {
value: Number(params.get('responseStart')),
enumerable: true,
writeable: true,
});
}

function block(blockingTime) {
const startTime = performance.now();
while (performance.now() < startTime + blockingTime) {
// Block...
}
}

if (params.has('earlyHintsDelay')) {
const earlyHintsDelay = Number(params.get('earlyHintsDelay'))
// Block for delay time—to avoid the library seeing future timestamps,
// and so not reporting a TTFB at all as it's invalid.
block(Number(params.get('earlyHintsDelay')));
// Chrome >= 133 has both finalResponseHeadersStart and firstInterimResponseStart
if ('finalResponseHeadersStart' in PerformanceNavigationTiming.prototype) {
Object.defineProperties(navEntry, {
'firstInterimResponseStart': {
value: Number(navEntry.responseStart),
enumerable: true,
},
'finalResponseHeadersStart': {
value: Number(navEntry.responseStart + earlyHintsDelay),
enumerable: true,
}
});
} else if ('firstInterimResponseStart' in PerformanceNavigationTiming.prototype) {
// TODO: Can remove these after Chrome 133 lands and above is used.
Object.defineProperties(navEntry, {
'firstInterimResponseStart': {
value: Number(navEntry.responseStart),
enumerable: true,
},
'responseStart': {
value: Number(navEntry.responseStart + earlyHintsDelay),
enumerable: true,
},
})
};
}
</script>

<script type="module">
Expand Down
Loading