webperf-snippets 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,44 +35,44 @@ npx webperf-snippets <url> [options]
35
35
  Run the default Core Web Vitals workflow (LCP + CLS, plus LCP-Subparts if LCP > 2.5s):
36
36
 
37
37
  ```bash
38
- npx webperf-snippets https://web.dev
38
+ npx webperf-snippets https://example.com
39
39
  ```
40
40
 
41
41
  Loading audit (11 steps: TTFB, FCP, render-blocking, scripts, resource timings, third-party impact, fonts, cache; the list is in the [CLI page](https://webperf-snippets.net/CLI)):
42
42
 
43
43
  ```bash
44
- npx webperf-snippets https://web.dev --workflow loading
44
+ npx webperf-snippets https://example.com --workflow loading
45
45
  ```
46
46
 
47
47
  Structural checks for CI (22 checks: render-blocking, compression, fonts, images, hints, DOM size and more; the list is in the [CLI page](https://webperf-snippets.net/CLI)):
48
48
 
49
49
  ```bash
50
- npx webperf-snippets https://web.dev --workflow audit
50
+ npx webperf-snippets https://example.com --workflow audit
51
51
  ```
52
52
 
53
53
  Markdown output for PR comments:
54
54
 
55
55
  ```bash
56
- npx webperf-snippets https://web.dev --markdown
56
+ npx webperf-snippets https://example.com --markdown
57
57
  ```
58
58
 
59
59
  JSON output (for piping into `jq` or CI):
60
60
 
61
61
  ```bash
62
- npx webperf-snippets https://web.dev --json
62
+ npx webperf-snippets https://example.com --json
63
63
  ```
64
64
 
65
65
  Single snippet:
66
66
 
67
67
  ```bash
68
- npx webperf-snippets https://web.dev --snippet LCP-Subparts
68
+ npx webperf-snippets https://example.com --snippet LCP-Subparts
69
69
  ```
70
70
 
71
71
  Synthetic INP measurement with an interaction script:
72
72
 
73
73
  ```bash
74
- npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
75
- npx webperf-snippets https://web.dev --snippet Interaction/Interactions --interact-script interactions.json
74
+ npx webperf-snippets https://example.com --snippet INP --interact-script interactions.json
75
+ npx webperf-snippets https://example.com --snippet Interaction/Interactions --interact-script interactions.json
76
76
  ```
77
77
 
78
78
  The Interaction snippets (`Interactions`, `Input-Latency-Breakdown`, `LongTask`, `Forced-Synchronous-Layout`...) are installed before the first step and read after the last one, so their result holds what the steps caused.
@@ -82,7 +82,7 @@ The CLI waits for the browser to report the timing of the last interaction befor
82
82
  Interaction audit (long tasks, long animation frames, scroll, layout shifts, forced layouts, interaction latency). It scrolls the page, or runs your `--interact-script`; the steps that need a click are skipped when the script has none:
83
83
 
84
84
  ```bash
85
- npx webperf-snippets https://web.dev --workflow interaction
85
+ npx webperf-snippets https://example.com --workflow interaction
86
86
  ```
87
87
 
88
88
  Pages behind a login, with a Playwright storage state (cookies and localStorage):
@@ -100,7 +100,7 @@ The file holds live session cookies. Keep it out of version control (add it to `
100
100
  Compare the measured values with CrUX field data (what real users experience at the 75th percentile):
101
101
 
102
102
  ```bash
103
- CRUX_API_KEY=your-key npx webperf-snippets https://web.dev
103
+ CRUX_API_KEY=your-key npx webperf-snippets https://example.com
104
104
  ```
105
105
 
106
106
  ```
@@ -134,7 +134,7 @@ Without `--report-to` the CLI makes no external calls. With it, the results are
134
134
  "timestamp": "2026-05-06T10:00:00.000Z",
135
135
  "navMs": 1240,
136
136
  "results": [],
137
- "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.0.0" }
137
+ "meta": { "viewport": "mobile", "waitMs": 3000, "cli_version": "1.1.0" }
138
138
  }
139
139
  ```
140
140
 
@@ -147,7 +147,7 @@ For a `--snippet` run, `workflow` is `null` and a `snippet` field carries the na
147
147
  CI gating:
148
148
 
149
149
  ```bash
150
- npx webperf-snippets https://web.dev --budget-lcp 2500 --budget-cls 0.1
150
+ npx webperf-snippets https://example.com --budget-lcp 2500 --budget-cls 0.1
151
151
  ```
152
152
 
153
153
  ### Options
@@ -204,6 +204,45 @@ The results of a run can add steps on the same page load: LCP over 2.5 s adds `L
204
204
  | `1` | Budget violation, a snippet errored, or a step of the workflow reported an `error` issue. |
205
205
  | `2` | Usage error (missing URL, unknown workflow). |
206
206
 
207
+ ## MCP server
208
+
209
+ `webperf-snippets mcp` starts a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, so an AI agent can call the snippets by name instead of reading their source. Add it to a client that launches local servers, for example Claude Code:
210
+
211
+ ```bash
212
+ claude mcp add webperf -- npx -y webperf-snippets mcp
213
+ ```
214
+
215
+ | Tool | What it does |
216
+ | --------------- | ---------------------------------------------------------------------------- |
217
+ | `list_snippets` | Lists the snippets with their category, title, short aliases and whether they track interactions. Takes an optional `category`. |
218
+ | `run_snippet` | Opens a URL in a headless Chromium, runs one snippet and returns the object it returns. Takes `url`, `snippet` (name, alias or `Category/Name`), and optional `viewport` and `waitMs`. The snippets that record interactions run on a page opened with `open_page`. |
219
+ | `run_workflow` | Runs a workflow of the CLI (`core-web-vitals`, `audit`, `loading`, `interaction`) and the follow-ups of its decision tree. Takes `url`, `workflow`, and optional `viewport` and `waitMs`. |
220
+ | `get_crux` | Returns the CrUX p75 of LCP, CLS and INP for a URL, or its origin. Takes `url` and an optional `formFactor` (`phone`, `desktop`, `tablet`). Needs `CRUX_API_KEY` in the environment of the server. |
221
+ | `open_page` | Opens a URL in a page that stays open between calls and installs tracking snippets (`track`, such as `INP`). Returns a `sessionId`. |
222
+ | `interact` | Runs steps on an open page: `click`, `type`, `hover`, `scroll` or `wait`. |
223
+ | `read_results` | Returns what the tracking snippets of an open page have recorded. |
224
+ | `close_page` | Closes an open page. |
225
+
226
+ The server also offers resources, documents a client can attach to the context:
227
+
228
+ | Resource | What it has |
229
+ | -------------------------------- | ------------------------------------------------------------------------ |
230
+ | `webperf://schema` | The contract every snippet result follows: base shape, statuses, how to read a result. |
231
+ | `webperf://schema/{snippet}` | The fields one snippet returns, with an example. |
232
+ | `webperf://workflows/{category}` | The workflows and the decision tree of a category. |
233
+
234
+ And prompts, templates that chain the tools (in Claude Code, `/mcp__webperf__audit_lcp`): `audit_lcp`, `debug_inp` and `audit_page`, each with a `url` argument.
235
+
236
+ The CrUX key goes in the configuration of the client, never in a tool argument:
237
+
238
+ ```bash
239
+ claude mcp add webperf -e CRUX_API_KEY=<key> -- npx -y webperf-snippets mcp
240
+ ```
241
+
242
+ At most three pages are open at a time, a page closes after 10 minutes without use, and every page and Chromium close when the client disconnects.
243
+
244
+ The server answers clients of both eras of the protocol: the 2026-07-28 revision and the earlier ones that open with `initialize`. [ADR 0002](../docs/adr/0002-mcp-server-as-cli-subcommand.md) explains why it is a subcommand and uses stdio.
245
+
207
246
  ## CI example
208
247
 
209
248
  GitHub Actions, fail the PR if LCP exceeds 2.5s:
@@ -212,7 +251,7 @@ GitHub Actions, fail the PR if LCP exceeds 2.5s:
212
251
  - run: |
213
252
  npm install --no-save webperf-snippets playwright
214
253
  npx playwright install --with-deps chromium
215
- npx webperf-snippets https://staging.web.dev --budget-lcp 2500 --budget-cls 0.1
254
+ npx webperf-snippets https://staging.example.com --budget-lcp 2500 --budget-cls 0.1
216
255
  ```
217
256
 
218
257
  ### GitHub Action
@@ -308,9 +347,9 @@ The `NPM_TOKEN` secret must be set in the repository settings with publish acces
308
347
  ## How it works
309
348
 
310
349
  1. Launches headless chromium via Playwright.
311
- 2. Pre-registers `PerformanceObserver`s for LCP and layout-shift before navigation (Chrome doesn't expose these via `getEntriesByType` without a buffered observer, so the runner shims it).
312
- 3. Navigates, waits for the page to settle.
313
- 4. Evaluates each snippet's IIFE in the page context, capturing the returned object.
350
+ 2. Navigates to the URL, waits for the `load` event and then for `--wait` milliseconds (3000 by default), so late entries reach the page.
351
+ 3. Evaluates each snippet's IIFE in the page context, capturing the returned object. The snippets read the entries Chrome only exposes to observers (LCP, layout shifts, long tasks, event timing) with a buffered `PerformanceObserver`, so they see the entries recorded before they ran.
352
+ 4. With an interaction script, installs the tracking snippets first, runs the interactions, lets the page settle and then reads the tracking data and runs the other snippets.
314
353
  5. Applies the workflow's decision tree to enqueue follow-up snippets.
315
354
  6. Renders results (human or JSON) and exits with an appropriate code.
316
355
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "webperf-snippets",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
+ "mcpName": "io.github.nucliweb/webperf-snippets",
4
5
  "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.",
5
6
  "type": "module",
6
7
  "bin": "./src/bin.js",
@@ -15,8 +16,12 @@
15
16
  "test:unit": "vitest run tests/unit",
16
17
  "test:e2e": "vitest run tests/e2e"
17
18
  },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/server": "^2.3.1"
21
+ },
18
22
  "devDependencies": {
19
- "vitest": "^4.1.11"
23
+ "@modelcontextprotocol/client": "^2.3.1",
24
+ "vitest": "^5.0.5"
20
25
  },
21
26
  "engines": {
22
27
  "node": ">=20.12.0"
@@ -6,7 +6,7 @@
6
6
  console.warn("⚠️ event entries are not supported in this browser.");
7
7
  return { script: "INP", status: "unsupported", error: "event entries not supported in this browser" };
8
8
  }
9
- const interactions = [];
9
+ let interactions = [];
10
10
  let inpValue = 0;
11
11
  let inpEntry = null;
12
12
 
@@ -21,6 +21,104 @@
21
21
 
22
22
  const formatMs = (ms) => `${Math.round(ms)}ms`;
23
23
 
24
+ // @shared createInteractionTracker
25
+ // Groups Event Timing entries by interactionId across observer callbacks, since the entries of one
26
+ // interaction (pointerdown, pointerup, click; keydown, keyup) can arrive in separate batches. Each
27
+ // interaction is measured over its entries painted in the frame of its longest one: Chrome rounds
28
+ // durations to 8 ms, and an event painted in an earlier frame, such as the keydown of a key held
29
+ // down, has its own timing. It is reported under the event whose handler ran longest, or under the
30
+ // click or keydown when no handler took a millisecond more than theirs.
31
+ function createInteractionTracker() {
32
+ const byId = new Map();
33
+ const processing = (e) => e.processingEnd - e.processingStart;
34
+ const measure = (id, entries) => {
35
+ const longest = entries.reduce((a, b) => (b.duration > a.duration ? b : a));
36
+ const paint = longest.startTime + longest.duration;
37
+ const frame = entries.filter((e) => Math.abs(e.startTime + e.duration - paint) <= 8);
38
+ const startTime = Math.min(...frame.map((e) => e.startTime));
39
+ const processingStart = Math.min(...frame.map((e) => e.processingStart));
40
+ const processingEnd = Math.max(...frame.map((e) => e.processingEnd));
41
+ const busiest = frame.reduce((a, b) => (processing(b) > processing(a) ? b : a));
42
+ const primary = frame.find((e) => e.name === "click" || e.name === "keydown");
43
+ const reported = primary && processing(busiest) - processing(primary) < 1 ? primary : busiest;
44
+ return {
45
+ interactionId: id,
46
+ name: reported.name,
47
+ target: reported.target ?? frame.find((e) => e.target)?.target ?? null,
48
+ startTime,
49
+ duration: longest.duration,
50
+ phases: {
51
+ inputDelay: processingStart - startTime,
52
+ processingTime: processingEnd - processingStart,
53
+ presentationDelay: Math.max(0, startTime + longest.duration - processingEnd),
54
+ },
55
+ };
56
+ };
57
+ return {
58
+ // Adds the entries of one observer callback and returns the interactions they changed
59
+ add(entries) {
60
+ const changed = new Set();
61
+ for (const e of entries) {
62
+ if (!e.interactionId) continue;
63
+ const record = byId.get(e.interactionId) ?? { entries: [] };
64
+ record.entries.push({
65
+ name: e.name,
66
+ target: e.target,
67
+ startTime: e.startTime,
68
+ duration: e.duration,
69
+ processingStart: e.processingStart,
70
+ processingEnd: e.processingEnd,
71
+ });
72
+ byId.set(e.interactionId, record);
73
+ changed.add(e.interactionId);
74
+ }
75
+ return [...changed].map((id) => {
76
+ const record = byId.get(id);
77
+ record.summary = measure(id, record.entries);
78
+ return record.summary;
79
+ });
80
+ },
81
+ list() {
82
+ return [...byId.values()].map((record) => record.summary);
83
+ },
84
+ };
85
+ }
86
+ // @end-shared createInteractionTracker
87
+
88
+ // @shared uniqueSelector
89
+ // A CSS selector that matches only el in its document (or shadow root): its id when it is unique,
90
+ // otherwise a path of tag, classes and :nth-of-type() that goes up to the nearest ancestor with a
91
+ // unique id, or to the root, and stops as soon as it matches one element.
92
+ function uniqueSelector(el) {
93
+ const root = el.getRootNode();
94
+ const matchesOne = (selector) => {
95
+ try {
96
+ return root.querySelectorAll(selector).length === 1;
97
+ } catch {
98
+ return false;
99
+ }
100
+ };
101
+ const parts = [];
102
+ let node = el;
103
+ while (node && node.nodeType === 1) {
104
+ if (node.id && matchesOne(`#${window.CSS.escape(node.id)}`)) {
105
+ parts.unshift(`#${window.CSS.escape(node.id)}`);
106
+ break;
107
+ }
108
+ const tag = node.localName;
109
+ let part = tag + Array.from(node.classList).slice(0, 2).map((c) => `.${window.CSS.escape(c)}`).join("");
110
+ const siblings = node.parentNode ? Array.from(node.parentNode.children) : [node];
111
+ if (siblings.filter((s) => s.matches(part)).length > 1) {
112
+ part += `:nth-of-type(${siblings.filter((s) => s.localName === tag).indexOf(node) + 1})`;
113
+ }
114
+ parts.unshift(part);
115
+ if (matchesOne(parts.join(" > "))) break;
116
+ node = node.parentElement;
117
+ }
118
+ return parts.join(" > ");
119
+ }
120
+ // @end-shared uniqueSelector
121
+
24
122
  // Calculate INP (98th percentile of all interactions)
25
123
  const calculateINP = () => {
26
124
  if (interactions.length === 0) return { value: 0, entry: null };
@@ -54,64 +152,18 @@
54
152
  return `${entry.name} → ${selector}`;
55
153
  };
56
154
 
57
- // Get phase breakdown from the Event Timing entry
58
- const getPhaseBreakdown = (entry) => {
59
- const phases = {
60
- inputDelay: 0,
61
- processingTime: 0,
62
- presentationDelay: 0,
63
- };
64
-
65
- if (entry.processingStart && entry.processingEnd) {
66
- phases.inputDelay = entry.processingStart - entry.startTime;
67
- phases.processingTime = entry.processingEnd - entry.processingStart;
68
- phases.presentationDelay = Math.max(
69
- 0,
70
- entry.duration - phases.inputDelay - phases.processingTime
71
- );
72
- }
73
-
74
- return phases;
155
+ // One record per interaction (interactionId), with its phases measured over all its entries
156
+ const tracker = createInteractionTracker();
157
+ const addEntries = (entries) => {
158
+ tracker.add(entries);
159
+ interactions = tracker.list().map((i) => ({ ...i, formattedName: getInteractionName(i) }));
160
+ const result = calculateINP();
161
+ inpValue = result.value;
162
+ inpEntry = result.entry;
75
163
  };
76
164
 
77
165
  // Observer for interactions
78
- const observer = new PerformanceObserver((list) => {
79
- for (const entry of list.getEntries()) {
80
- // Only track interactions with interactionId (meaningful interactions)
81
- if (!entry.interactionId) continue;
82
-
83
- // Avoid duplicate entries for the same interaction
84
- const existing = interactions.find(
85
- (i) => i.interactionId === entry.interactionId
86
- );
87
-
88
- if (!existing || entry.duration > existing.duration) {
89
- // Remove old entry if exists
90
- if (existing) {
91
- const idx = interactions.indexOf(existing);
92
- interactions.splice(idx, 1);
93
- }
94
-
95
- interactions.push({
96
- name: entry.name,
97
- duration: entry.duration,
98
- startTime: entry.startTime,
99
- interactionId: entry.interactionId,
100
- target: entry.target,
101
- processingStart: entry.processingStart,
102
- processingEnd: entry.processingEnd,
103
- formattedName: getInteractionName(entry),
104
- phases: getPhaseBreakdown(entry),
105
- entry,
106
- });
107
- }
108
-
109
- // Recalculate INP
110
- const result = calculateINP();
111
- inpValue = result.value;
112
- inpEntry = result.entry;
113
- }
114
- });
166
+ const observer = new PerformanceObserver((list) => addEntries(list.getEntries()));
115
167
 
116
168
  // Observe event timing
117
169
  observer.observe({
@@ -324,7 +376,13 @@
324
376
  if (interactions.length === 0) {
325
377
  console.log(" No interactions recorded yet.");
326
378
  console.groupEnd();
327
- return [];
379
+ return {
380
+ script: "INP",
381
+ status: "error",
382
+ error: "No interactions recorded yet. Interact with the page and call getINPDetails() again.",
383
+ count: 0,
384
+ items: [],
385
+ };
328
386
  }
329
387
 
330
388
  const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
@@ -401,16 +459,30 @@
401
459
  }
402
460
 
403
461
  console.groupEnd();
404
- return sorted;
462
+ // The 15 slowest, as plain data: the target is a selector, not the element
463
+ return {
464
+ script: "INP",
465
+ status: "ok",
466
+ count: sorted.length,
467
+ items: sorted.slice(0, maxToShow).map((i) => ({
468
+ formattedName: i.formattedName,
469
+ eventType: i.name,
470
+ target: i.target ? uniqueSelector(i.target) : null,
471
+ duration: Math.round(i.duration),
472
+ startTime: Math.round(i.startTime),
473
+ phases: {
474
+ inputDelay: Math.round(i.phases.inputDelay),
475
+ processingTime: Math.round(i.phases.processingTime),
476
+ presentationDelay: Math.round(i.phases.presentationDelay),
477
+ },
478
+ })),
479
+ };
405
480
  };
406
481
 
407
482
  // Log on page hide (final INP)
408
483
  document.addEventListener("visibilitychange", () => {
409
484
  if (document.visibilityState === "hidden") {
410
- observer.takeRecords();
411
- const result = calculateINP();
412
- inpValue = result.value;
413
- inpEntry = result.entry;
485
+ addEntries(observer.takeRecords());
414
486
 
415
487
  console.log("%c📊 Final INP (on page hide):", "font-weight: bold;");
416
488
  logINP();
@@ -18,6 +18,26 @@
18
18
 
19
19
  const LCP_THRESHOLD = 0.05; // Chrome's threshold for low-entropy
20
20
 
21
+ // Chrome divides the encoded size by the area the image is displayed at, in CSS pixels (not
22
+ // device pixels), and an upscaled image by its natural area instead, so stretching an image never
23
+ // lowers its bits per pixel. Chrome uses the part inside the viewport; this uses the whole box.
24
+ // https://w3c.github.io/largest-contentful-paint/ (steps 8.9 to 8.13) and Chromium's
25
+ // LargestContentfulPaintCalculator::ComputeEffectiveVisualSize
26
+ function displayedSize(img) {
27
+ const rect = img.getBoundingClientRect();
28
+ const style = getComputedStyle(img);
29
+ const px = (prop) => parseFloat(style[prop]) || 0;
30
+ return {
31
+ width: Math.max(0, rect.width - px("borderLeftWidth") - px("borderRightWidth") - px("paddingLeft") - px("paddingRight")),
32
+ height: Math.max(0, rect.height - px("borderTopWidth") - px("borderBottomWidth") - px("paddingTop") - px("paddingBottom")),
33
+ };
34
+ }
35
+ function bitsPerPixel(img, fileSize) {
36
+ const displayed = displayedSize(img);
37
+ const pixels = Math.min(displayed.width * displayed.height, img.naturalWidth * img.naturalHeight);
38
+ return { displayed, bpp: pixels > 0 ? (fileSize * 8) / pixels : 0 };
39
+ }
40
+
21
41
  // Get current LCP element
22
42
  let lcpElement = null;
23
43
  let lcpUrl = null;
@@ -47,8 +67,7 @@
47
67
  const src = img.currentSrc || img.src;
48
68
  const resource = performance.getEntriesByName(src)[0];
49
69
  const fileSize = resource?.encodedBodySize || 0;
50
- const pixels = img.naturalWidth * img.naturalHeight;
51
- const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
70
+ const { displayed, bpp } = bitsPerPixel(img, fileSize);
52
71
 
53
72
  const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
54
73
  const isLCP = lcpElement === img || lcpUrl === src;
@@ -59,6 +78,8 @@
59
78
  shortSrc: src.split("/").pop()?.split("?")[0] || src,
60
79
  width: img.naturalWidth,
61
80
  height: img.naturalHeight,
81
+ displayedWidth: Math.round(displayed.width),
82
+ displayedHeight: Math.round(displayed.height),
62
83
  fileSize,
63
84
  bpp,
64
85
  isLowEntropy,
@@ -105,6 +126,7 @@
105
126
  .map((img) => ({
106
127
  Image: img.shortSrc.length > 30 ? "..." + img.shortSrc.slice(-27) : img.shortSrc,
107
128
  Dimensions: `${img.width}×${img.height}`,
129
+ Displayed: `${img.displayedWidth}×${img.displayedHeight}`,
108
130
  Size: formatBytes(img.fileSize),
109
131
  BPP: img.bpp.toFixed(4),
110
132
  Entropy: img.isLowEntropy ? "🔴 Low" : "🟢 Normal",
@@ -166,14 +188,15 @@
166
188
  const src = img.currentSrc || img.src;
167
189
  const resource = performance.getEntriesByName(src)[0];
168
190
  const fileSize = resource?.encodedBodySize || 0;
169
- const pixels = img.naturalWidth * img.naturalHeight;
170
- const bpp = pixels > 0 ? (fileSize * 8) / pixels : 0;
191
+ const { displayed, bpp } = bitsPerPixel(img, fileSize);
171
192
  const isLowEntropy = bpp > 0 && bpp < LCP_THRESHOLD;
172
193
  const isLCP = lcpElementSync === img || lcpUrlSync === src;
173
194
  return {
174
195
  url: src.split("/").pop()?.split("?")[0] || src,
175
196
  width: img.naturalWidth,
176
197
  height: img.naturalHeight,
198
+ displayedWidth: Math.round(displayed.width),
199
+ displayedHeight: Math.round(displayed.height),
177
200
  fileSizeBytes: fileSize,
178
201
  bpp: Math.round(bpp * 10000) / 10000,
179
202
  isLowEntropy,
@@ -67,6 +67,15 @@
67
67
  }
68
68
  // @end-shared uniqueSelector
69
69
 
70
+ // The Resource Load Time splits into the wait for the server, until the first byte of the response,
71
+ // and the download of the body: a slow server and a heavy image need different fixes. Another
72
+ // origin without Timing-Allow-Origin reports responseStart as 0, and then there is no split.
73
+ const splitLoadTime = (resEntry, requestStart, responseEnd, activationStart) => {
74
+ if (!resEntry?.responseStart) return null;
75
+ const firstByte = Math.min(Math.max(requestStart, resEntry.responseStart - activationStart), responseEnd);
76
+ return { waiting: firstByte - requestStart, download: responseEnd - firstByte };
77
+ };
78
+
70
79
  const getNavigationEntry = () => {
71
80
  const navEntry = performance.getEntriesByType("navigation")[0];
72
81
  if (navEntry?.responseStart > 0 && navEntry.responseStart < performance.now()) {
@@ -108,6 +117,8 @@
108
117
  lcpEntry.startTime - activationStart
109
118
  );
110
119
 
120
+ const loadSplit = splitLoadTime(lcpResEntry, lcpRequestStart, lcpResponseEnd, activationStart);
121
+
111
122
  const subPartValues = {
112
123
  ttfb: ttfb,
113
124
  resourceLoadDelay: lcpRequestStart - ttfb,
@@ -196,6 +207,9 @@
196
207
  console.log("");
197
208
  console.log("%c💡 Optimization Focus:", "font-weight: bold; color: #3b82f6;");
198
209
  console.log(` Slowest phase: ${slowest.name} (${formatPercent(slowest.value, lcpRenderTime)})`);
210
+ if (slowest.key === "resourceLoadTime" && loadSplit) {
211
+ console.log(` ${formatMs(loadSplit.waiting)} waiting for the server, ${formatMs(loadSplit.download)} downloading the resource`);
212
+ }
199
213
 
200
214
  if (slowest.key === "ttfb") {
201
215
  console.log(" → Use a CDN to reduce latency");
@@ -205,6 +219,10 @@
205
219
  console.log(" → Preload the LCP image: <link rel=\"preload\" as=\"image\" href=\"...\">");
206
220
  console.log(" → Remove render-blocking resources");
207
221
  console.log(" → Inline critical CSS");
222
+ } else if (slowest.key === "resourceLoadTime" && loadSplit && loadSplit.waiting > loadSplit.download) {
223
+ console.log(" → Reduce the server response time of the LCP resource");
224
+ console.log(" → Serve it from a CDN, with Cache-Control headers");
225
+ console.log(" → Check that it is cached, not generated on each request");
208
226
  } else if (slowest.key === "resourceLoadTime") {
209
227
  console.log(" → Compress and resize the LCP image");
210
228
  console.log(" → Use modern formats (WebP, AVIF)");
@@ -233,7 +251,9 @@
233
251
 
234
252
  console.log("");
235
253
  console.log("%c📊 Measures added to Performance timeline", "color: #666;");
236
- console.log(" Open DevTools → Performance → reload to see waterfall");
254
+ console.log(
255
+ ' Read them with performance.getEntriesByType("measure"), or run the snippet during a DevTools Performance recording to see them in the Timings track'
256
+ );
237
257
 
238
258
  console.groupEnd();
239
259
  });
@@ -275,6 +295,8 @@
275
295
  const loadDelayVal = Math.round(lcpRequestStartSync - ttfbSync);
276
296
  const loadTimeVal = Math.round(lcpResponseEndSync - lcpRequestStartSync);
277
297
  const renderDelayVal = Math.round(lcpRenderTimeSync - lcpResponseEndSync);
298
+ const loadSplitSync = splitLoadTime(lcpResEntrySync, lcpRequestStartSync, lcpResponseEndSync, activationStartSync);
299
+ const waitingVal = loadSplitSync ? Math.min(loadTimeVal, Math.round(loadSplitSync.waiting)) : null;
278
300
  const subPartsForRank = [
279
301
  { key: "ttfb", value: ttfbVal },
280
302
  { key: "resourceLoadDelay", value: loadDelayVal },
@@ -310,7 +332,12 @@
310
332
  subParts: {
311
333
  ttfb: { value: ttfbVal, percent: Math.round((ttfbVal / totalSync) * 100), overTarget: ttfbVal > 800 },
312
334
  resourceLoadDelay: { value: loadDelayVal, percent: Math.round((loadDelayVal / totalSync) * 100), overTarget: (loadDelayVal / totalSync) * 100 > 10 },
313
- resourceLoadTime: { value: loadTimeVal, percent: Math.round((loadTimeVal / totalSync) * 100), overTarget: (loadTimeVal / totalSync) * 100 > 40 },
335
+ resourceLoadTime: {
336
+ value: loadTimeVal,
337
+ percent: Math.round((loadTimeVal / totalSync) * 100),
338
+ overTarget: (loadTimeVal / totalSync) * 100 > 40,
339
+ ...(loadSplitSync ? { waiting: waitingVal, download: loadTimeVal - waitingVal } : {}),
340
+ },
314
341
  elementRenderDelay: { value: renderDelayVal, percent: Math.round((renderDelayVal / totalSync) * 100), overTarget: (renderDelayVal / totalSync) * 100 > 10 },
315
342
  },
316
343
  slowestPhase: slowestPhaseSync,
@@ -34,7 +34,7 @@ When layout shifts are detected or the user asks "debug CLS" or "layout shift is
34
34
 
35
35
  When interactions feel slow or the user asks "debug INP" or "slow interactions":
36
36
 
37
- 1. **INP.js** - Measure overall INP value; call `getINP()` after interactions, `getINPDetails()` for full list
37
+ 1. **INP.js** - Measure overall INP value; call `getINP()` after interactions, `getINPDetails()` for the 15 slowest interactions
38
38
  2. **Interactions.js** _(pending — available in webperf-interaction skill)_
39
39
  3. **Input-Latency-Breakdown.js** _(pending — available in webperf-interaction skill)_
40
40
  4. **Long-Animation-Frames.js** _(pending — available in webperf-interaction skill)_
@@ -134,7 +134,7 @@ Use this decision tree to automatically run follow-up snippets based on results:
134
134
  3. **webperf-interaction:Long-Animation-Frames.js** (blocking frames)
135
135
  4. **webperf-interaction:Long-Animation-Frames-Script-Attribution.js** (culprit scripts)
136
136
  - **If specific interaction type is slow (e.g., keyboard)** → Focus analysis on that interaction type
137
- - **After INP data is collected** → call `getINPDetails()` for the full sorted interaction list (useful for identifying patterns across multiple slow interactions)
137
+ - **After INP data is collected** → call `getINPDetails()` for the 15 slowest interactions, slowest first (useful for identifying patterns across multiple slow interactions)
138
138
 
139
139
  ### Error Recovery
140
140