Skip to content
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

Add interoperable TTFB to measure Early Hints consistently #566

Open
wants to merge 13 commits into
base: v5
Choose a base branch
from
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
11 changes: 10 additions & 1 deletion src/attribution/onLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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 ||
tunetheweb marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -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
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
Loading