public/js/lib/vitals.js
// Core Web Vitals, collected from page load onward — the same algorithms the
// web-vitals library uses, in ~100 lines (§83). Started by main.js on load,
// so the numbers cover the whole visit, not just the Vitals station.
export const THRESHOLDS = {
LCP: [2500, 4000], // ms
INP: [200, 500], // ms
CLS: [0.1, 0.25], // unitless
FCP: [1800, 3000],
TTFB: [800, 1800],
};
export const rating = (name, v) =>
v == null ? "none" : v <= THRESHOLDS[name][0] ? "good" : v <= THRESHOLDS[name][1] ? "needs-improvement" : "poor";
export const vitals = { LCP: null, INP: null, CLS: 0, FCP: null, TTFB: null, lcpElement: null, interactions: 0, last: null };
export const loafs = []; // recent long animation frames
const listeners = new Set();
export const onVitals = (fn) => (listeners.add(fn), () => listeners.delete(fn));
const emit = () => listeners.forEach((fn) => fn(vitals));
const supported = PerformanceObserver.supportedEntryTypes ?? [];
const observe = (type, cb, opts = {}) => {
if (!supported.includes(type)) return false;
new PerformanceObserver((list) => cb(list.getEntries())).observe({ type, buffered: true, ...opts });
return true;
};
export const support = {
lcp: supported.includes("largest-contentful-paint"),
cls: supported.includes("layout-shift"),
inp: supported.includes("event"),
loaf: supported.includes("long-animation-frame"),
};
// TTFB: from the navigation entry.
const nav = performance.getEntriesByType("navigation")[0];
if (nav) vitals.TTFB = nav.responseStart - nav.startTime;
// FCP
observe("paint", (entries) => {
for (const e of entries) if (e.name === "first-contentful-paint") vitals.FCP = e.startTime;
emit();
});
// LCP: the last candidate reported before the first input wins.
observe("largest-contentful-paint", (entries) => {
const last = entries.at(-1);
vitals.LCP = last.startTime;
vitals.lcpElement = last.element;
emit();
});
// CLS: shifts are grouped into "session windows" (gap < 1s, window < 5s);
// CLS is the largest window. Shifts right after input don't count.
let windowValue = 0;
let windowEntries = [];
observe("layout-shift", (entries) => {
for (const e of entries) {
if (e.hadRecentInput) continue;
const first = windowEntries[0];
const last = windowEntries.at(-1);
if (last && e.startTime - last.startTime < 1000 && e.startTime - first.startTime < 5000) {
windowValue += e.value;
windowEntries.push(e);
} else {
windowValue = e.value;
windowEntries = [e];
}
vitals.CLS = Math.max(vitals.CLS, windowValue);
}
emit();
});
// INP: the slowest interaction (or the 98th percentile once there are
// 50+ interactions). One interaction = all events sharing an interactionId.
const byInteraction = new Map();
const updateINP = (entries) => {
for (const e of entries) {
if (!e.interactionId) continue;
const d = Math.max(byInteraction.get(e.interactionId) ?? 0, e.duration);
byInteraction.set(e.interactionId, d);
vitals.last = { name: e.name, duration: d, target: e.target?.textContent?.trim().slice(0, 40) };
}
const all = [...byInteraction.values()].sort((a, b) => b - a);
vitals.interactions = all.length;
vitals.INP = all.length ? all[Math.min(all.length - 1, Math.floor(all.length / 50))] : null;
emit();
};
observe("event", updateINP, { durationThreshold: 16 });
observe("first-input", updateINP);
// Long Animation Frames: which frames were slow, and which scripts made them slow.
observe("long-animation-frame", (entries) => {
for (const e of entries) {
loafs.unshift({
start: e.startTime,
duration: e.duration,
blocking: e.blockingDuration,
scripts: e.scripts.map((s) => ({ invoker: s.invoker, source: s.sourceURL?.split("/").pop(), duration: s.duration })),
});
}
loafs.length = Math.min(loafs.length, 15);
emit();
});
export const resetINP = () => {
byInteraction.clear();
updateINP([]);
};