Public: anyone can read this; the crew can change it.

webvitals(): LCP, CLS and TBT from recorded performance entries, graded against specification.website thresholds #2985

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/2982-webvitals-lcp-cls-and-tbt-from-recorded → main 1 commit fork

  • Merged. torque/2982-webvitals-lcp-cls-and-tbt-from-recorded was merged into main as 1b3e2e2.

Review

Sign in to review this change.
2 files changed +224 −0
src/release/webvitals.ts added +144
@@ -0,0 +1,144 @@
1 /**
2 * The website specification's Core Web Vitals, graded from what a page
3 * recorded (#2982, part of #2915 check 9). `webvitals()` takes the
4 * `largest-contentful-paint`, `layout-shift` and `longtask` performance
5 * entries as plain data and returns LCP, CLS and TBT, each graded against the
6 * specification.website thresholds and citing its section. Pure: no browser,
7 * no clock, no I/O. Entries that are not finite, non-negative numbers are
8 * dropped at the boundary, never trusted.
9 *
10 * Thresholds (good / poor; between is needs-improvement), as web.dev states
11 * them and specification.website cites:
12 * - LCP: good <= 2500 ms, poor > 4000 ms.
13 * - CLS: good <= 0.1, poor > 0.25 (unitless).
14 * - INP: good <= 200 ms, poor > 500 ms. A lab run has no interactions, so
15 * Total Blocking Time stands in for it, graded with Lighthouse's TBT
16 * scale: good <= 200 ms, poor > 600 ms.
17 */
18
19 export type Grade = "good" | "needs-improvement" | "poor";
20
21 export interface Vital {
22 /** The measured value, or null when the page recorded no entry for it. */
23 value: number | null;
24 grade: Grade | null;
25 /** Upper bound of "good", inclusive. */
26 good: number;
27 /** Upper bound of "needs-improvement", inclusive; above it is "poor". */
28 poor: number;
29 unit: "ms" | "";
30 /** The specification.website section this vital cites. */
31 section: string;
32 }
33
34 export interface WebVitals {
35 lcpMs: Vital;
36 cls: Vital;
37 tbtMs: Vital;
38 }
39
40 /** The fields of a recorded entry this module reads; anything else is ignored. */
41 export interface Entries {
42 lcp?: readonly unknown[];
43 layoutShift?: readonly unknown[];
44 longtask?: readonly unknown[];
45 }
46
47 const SPEC = "https://specification.website/";
48 const s = (name: string) => `${SPEC}#${name}`;
49
50 export const LCP = { good: 2500, poor: 4000, unit: "ms", section: s("largest-contentful-paint") } as const;
51 export const CLS = { good: 0.1, poor: 0.25, unit: "", section: s("cumulative-layout-shift") } as const;
52 /** TBT is the lab proxy for INP (good <= 200 ms); poor follows Lighthouse's TBT scale. */
53 export const TBT = { good: 200, poor: 600, unit: "ms", section: s("interaction-to-next-paint") } as const;
54
55 /** A long task blocks the main thread for whatever it runs past 50 ms. */
56 export const BLOCKING_MS = 50;
57 /** CLS session windows: a gap of 1 s or more starts a new window, and no window spans more than 5 s. */
58 export const SESSION_GAP_MS = 1000;
59 export const SESSION_CAP_MS = 5000;
60
61 const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null);
62 const field = (e: unknown, k: string): unknown => (e && typeof e === "object" ? (e as Record<string, unknown>)[k] : undefined);
63 const list = (v: unknown): readonly unknown[] => (Array.isArray(v) ? v : []);
64
65 export function grade(value: number, t: { good: number; poor: number }): Grade {
66 return value <= t.good ? "good" : value <= t.poor ? "needs-improvement" : "poor";
67 }
68
69 function vital(value: number | null, t: { good: number; poor: number; unit: "ms" | ""; section: string }): Vital {
70 return { value, grade: value === null ? null : grade(value, t), good: t.good, poor: t.poor, unit: t.unit, section: t.section };
71 }
72
73 /**
74 * LCP is the render time of the largest candidate the page reported. The
75 * browser only reports a candidate larger than the last, so the largest
76 * `size` is the final one; without sizes the latest `startTime` stands.
77 * `startTime` is renderTime, or loadTime for cross-origin images without
78 * Timing-Allow-Origin.
79 */
80 export function lcpMs(entries: readonly unknown[]): number | null {
81 let best: { t: number; size: number } | null = null;
82 for (const e of entries) {
83 const t = num(field(e, "startTime"));
84 if (t === null) continue;
85 const size = num(field(e, "size")) ?? 0;
86 if (!best || size > best.size || (size === best.size && t > best.t)) best = { t, size };
87 }
88 return best && Math.round(best.t);
89 }
90
91 /**
92 * CLS is the largest session window's sum of shift values. Shifts within
93 * 500 ms of input (`hadRecentInput`) are expected and do not count. A window
94 * closes when the next shift comes 1 s or more after the previous one, or
95 * 5 s or more after the window's first.
96 */
97 export function cls(entries: readonly unknown[]): number {
98 const shifts: { t: number; v: number }[] = [];
99 for (const e of entries) {
100 if (field(e, "hadRecentInput") === true) continue;
101 const t = num(field(e, "startTime"));
102 const v = num(field(e, "value"));
103 if (t !== null && v !== null) shifts.push({ t, v });
104 }
105 shifts.sort((a, b) => a.t - b.t);
106 let max = 0;
107 let sum = 0;
108 let first = -Infinity;
109 let prev = -Infinity;
110 for (const { t, v } of shifts) {
111 if (t - prev >= SESSION_GAP_MS || t - first >= SESSION_CAP_MS) {
112 sum = 0;
113 first = t;
114 }
115 sum += v;
116 prev = t;
117 if (sum > max) max = sum;
118 }
119 // Four places hide float noise (0.07 + 0.03) without moving a grade.
120 return Math.round(max * 1e4) / 1e4;
121 }
122
123 /** TBT sums, over every long task, the part of its duration past 50 ms. */
124 export function tbtMs(entries: readonly unknown[]): number {
125 let total = 0;
126 for (const e of entries) {
127 const d = num(field(e, "duration"));
128 if (d !== null && d > BLOCKING_MS) total += d - BLOCKING_MS;
129 }
130 return Math.round(total);
131 }
132
133 /**
134 * Grades what a page recorded. A page that recorded no LCP candidate gets a
135 * null LCP; no shifts is a CLS of 0 and no long tasks a TBT of 0, both
136 * measured, not missing.
137 */
138 export function webvitals(input: Entries): WebVitals {
139 return {
140 lcpMs: vital(lcpMs(list(input?.lcp)), LCP),
141 cls: vital(cls(list(input?.layoutShift)), CLS),
142 tbtMs: vital(tbtMs(list(input?.longtask)), TBT),
143 };
144 }
test/webvitals.test.ts added +80
@@ -0,0 +1,80 @@
1 import { expect, test } from "bun:test";
2 import { cls, grade, lcpMs, tbtMs, webvitals, CLS, LCP, TBT } from "../src/release/webvitals.ts";
3
4 const shift = (startTime: number, value: number, hadRecentInput = false) => ({ entryType: "layout-shift", startTime, value, hadRecentInput });
5
6 test("LCP is the largest candidate's start time, not the latest entry", () => {
7 const entries = [
8 { entryType: "largest-contentful-paint", startTime: 800, size: 1200 },
9 { entryType: "largest-contentful-paint", startTime: 1900.4, size: 50000 },
10 { entryType: "largest-contentful-paint", startTime: 2100, size: 900 },
11 ];
12 expect(lcpMs(entries)).toBe(1900);
13 // Without sizes the latest candidate stands.
14 expect(lcpMs([{ startTime: 300 }, { startTime: 700 }])).toBe(700);
15 expect(lcpMs([])).toBeNull();
16 });
17
18 test("LCP grades at 2.5 s and 4 s, both bounds inclusive", () => {
19 expect([2500, 2501, 4000, 4001].map((v) => grade(v, LCP))).toEqual(["good", "needs-improvement", "needs-improvement", "poor"]);
20 const v = webvitals({ lcp: [{ startTime: 3200, size: 10 }] }).lcpMs;
21 expect(v).toEqual({ value: 3200, grade: "needs-improvement", good: 2500, poor: 4000, unit: "ms", section: LCP.section });
22 expect(v.section).toStartWith("https://specification.website/#");
23 });
24
25 test("CLS ignores shifts that had recent input", () => {
26 expect(cls([shift(100, 0.05), shift(200, 0.5, true), shift(300, 0.02)])).toBe(0.07);
27 });
28
29 test("CLS is the largest session window; a 1 s gap starts a new window", () => {
30 // Window A: 0 ms, 900 ms (gap 900 < 1000) -> 0.04 + 0.04 = 0.08.
31 // Window B: 1900 ms (gap exactly 1000, so new), 2500 ms -> 0.03 + 0.03 = 0.06.
32 expect(cls([shift(0, 0.04), shift(900, 0.04), shift(1900, 0.03), shift(2500, 0.03)])).toBe(0.08);
33 // Order of recording does not matter.
34 expect(cls([shift(2500, 0.03), shift(900, 0.04), shift(1900, 0.03), shift(0, 0.04)])).toBe(0.08);
35 });
36
37 test("CLS caps a session window at 5 s", () => {
38 // Shifts every 900 ms never leave a 1 s gap; the cap splits them at 5 s:
39 // 0..4500 ms (6 shifts, 0.3) and 5400..6300 ms (2 shifts, 0.1).
40 const shifts = [0, 900, 1800, 2700, 3600, 4500, 5400, 6300].map((t) => shift(t, 0.05));
41 expect(cls(shifts)).toBe(0.3);
42 // Uncapped this would have been 0.4.
43 expect(cls(shifts.slice(0, 6))).toBe(0.3);
44 });
45
46 test("CLS grades at 0.1 and 0.25, and hides float noise at the bound", () => {
47 // 0.07 + 0.03 is 0.09999999999999999 in floats; it reports and grades as 0.1.
48 const v = webvitals({ layoutShift: [shift(0, 0.07), shift(10, 0.03)] }).cls;
49 expect(v.value).toBe(0.1);
50 expect(v.grade).toBe("good");
51 expect([0.11, 0.25, 0.26].map((x) => grade(x, CLS))).toEqual(["needs-improvement", "needs-improvement", "poor"]);
52 expect(webvitals({}).cls).toMatchObject({ value: 0, grade: "good" });
53 });
54
55 test("TBT sums each long task's time past 50 ms", () => {
56 // 120 -> 70, 50 -> 0 (not over), 51 -> 1, 300 -> 250: 321 ms.
57 const tasks = [120, 50, 51, 300].map((duration, i) => ({ entryType: "longtask", startTime: i * 1000, duration }));
58 expect(tbtMs(tasks)).toBe(321);
59 expect(tbtMs([])).toBe(0);
60 });
61
62 test("TBT stands in for INP: good at 200 ms, poor past 600 ms", () => {
63 expect([200, 201, 600, 601].map((v) => grade(v, TBT))).toEqual(["good", "needs-improvement", "needs-improvement", "poor"]);
64 const v = webvitals({ longtask: [{ duration: 250 }] }).tbtMs;
65 expect(v).toMatchObject({ value: 200, grade: "good", unit: "ms" });
66 expect(v.section).toContain("interaction-to-next-paint");
67 });
68
69 test("junk at the boundary is dropped, not trusted", () => {
70 const junk = [null, 7, "x", { startTime: -5, size: 999 }, { startTime: NaN }, { startTime: "900" }, { startTime: Infinity }];
71 const v = webvitals({
72 lcp: [...junk, { startTime: 1200, size: 1 }],
73 layoutShift: [...junk, { startTime: 0, value: -1 }, { startTime: 0, value: "0.9" }, shift(5, 0.02)],
74 longtask: [...junk, { duration: -100 }, { duration: "900" }, { duration: 60 }],
75 });
76 expect([v.lcpMs.value, v.cls.value, v.tbtMs.value]).toEqual([1200, 0.02, 10]);
77 // Not arrays at all.
78 const none = webvitals({ lcp: "x", layoutShift: 3, longtask: {} } as never);
79 expect([none.lcpMs.value, none.lcpMs.grade, none.cls.value, none.tbtMs.value]).toEqual([null, null, 0, 0]);
80 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs