Skip to the content

Page-view vitals

Measures the Core Web Vitals (INP, CLS, LCP), FCP and TTFB for each page view with the rules of web-vitals, also for back/forward cache restores, prerendered pages and soft navigations.

PageViewVitals measures five metrics for each page view. The Core Web Vitals are Interaction to Next Paint (INP), Cumulative Layout Shift (CLS) and Largest Contentful Paint (LCP). The two load metrics are First Contentful Paint (FCP) and Time to First Byte (TTFB). The monitor applies the rules of the web-vitals library (version 6.2.3), with the differences in the limits section. The factory records one histogram value for each vital and page view, and sends browser.web_vital events with the names of the OpenTelemetry semantic conventions.

What it measures

A page view is one load of the page, one restore from the back/forward cache, or one soft navigation. Page views explains the unit. For each page view, the monitor gives these values:

VitalValue
INPThe longest interaction, with one outlier ignored for each 50 interactions.
CLSThe score of the worst session window of layout shifts without recent input.
LCPThe render time of the largest contentful paint before the page was hidden for the first time.
FCPThe time of the first contentful paint before the page was hidden for the first time.
TTFBThe time from the start of the navigation to the first byte of the response.

The monitor answers this question: how did each page view feel to its user, in the same units as CrUX and web-vitals? Each value also has an attribution, for example the CSS selector of the INP target. The events of the other monitors get the ID of the same page view. Thus you can connect a hang or a long frame with the vitals of its page view.

How it works

Observers

At its start, the monitor makes one PerformanceObserver, with buffered: true, for each of these entry types:

  • event with durationThreshold: 16, and first-input, for INP. The browser delivers first-input for all durations.
  • layout-shift, for CLS.
  • paint and largest-contentful-paint, for FCP and LCP.
  • soft-navigation and interaction-contentful-paint, only with softNavigations: true and only if the browser has the two types.

Usually the browser delivers the buffered entries in a later task. Old Safari gave them to the callback inside observe() (WebKit bug 247863). Then the observer gives them to the monitor in a microtask, as web-vitals does. Thus no FCP or layout shift from the buffer is lost.

The monitor makes no observer, and logs no warning, for a type that is not in PerformanceObserver.supportedEntryTypes. A vital whose entry type the browser does not have gets no value, as in web-vitals. INP needs event, CLS needs layout-shift, LCP needs largest-contentful-paint, and FCP needs paint. This rule also applies after a restore from the back/forward cache. TTFB needs the navigation entry instead.

ViewCollector calculates the values of one page view. InpCalculator and ClsCalculator give INP and CLS, with the rules of the Event Timing and layout shift monitors.

The rules of each vital

  • INP: the candidates are the event and first-input entries with an interaction ID, from the start of the page view. The calculator keeps the 10 longest interactions. The count comes from performance.interactionCount. The load counts from the start of the page, also when the monitor starts later. A later page view counts from its start.
  • INP of 0 and of 8 ms: a candidate of 0 ms gives an INP of 0. An example is a first input that the browser rounds to 0. After a restore or a soft navigation, the INP is 8 ms if interactions occurred but none gave an entry (SHORT_INTERACTION_ESTIMATE_MS).
  • INP attribution: the longest entry gives the latency and the start of the interaction. The processing phase spans all events of the frame of that entry, as in web-vitals. A frame is a group of event entries whose render times (startTime + duration) are within 8 ms of the first entry of the group. The events of the frame can belong to other interactions, or to no interaction, for example pointerover. The processing starts at the earliest processingStart of the frame, but not before the interaction. It ends at the latest processingEnd of the frame, but not after the next paint, also after a synchronous dialog, for example alert().
  • CLS: layout shifts without hadRecentInput, from the start of the page view. A new session window starts at a gap of 1 s or more, or when the window is 5 s long, as in web-vitals. For a load, the value comes only after FCP, as in CrUX, and it includes the shifts from before FCP. A restore and a soft navigation report CLS from their start, at 0.
  • CLS attribution: the selector of the largest shift of the worst session window. Of the sources of the shift, the selector names the first source with an element node, or else the first source, as in web-vitals. The collector makes the selector at the time of the shift, while the node is in the document.
  • LCP: the latest largest-contentful-paint entry whose startTime is before the first hidden time, minus activationStart. The latest entry is the last entry that the browser gave, also when its startTime (the load time) is earlier. The attribution is the selector of the element and the URL of the resource, without the query string and the fragment.
  • LCP finalization: the first trusted keydown or click after the start of the page view makes the LCP final at its time. Then a candidate that renders after that time does not count. As in web-vitals, a capture listener on the window gets these events, also for a fast input without an Event Timing entry. A keydown or click entry makes the LCP final too, for an input before the start of the monitor.
  • FCP: the first first-contentful-paint entry before the first hidden time, minus activationStart.
  • TTFB: responseStart minus activationStart, from the Navigation Timing entry. The page source ignores an entry whose responseStart is 0, or not before performance.now(), as web-vitals does.

The monitor records no value below 0. The first hidden time comes from the visibility-state entries with the name hidden, at or after activationStart. Without such entries, a page that is hidden at the start of the monitor was hidden from the start (time 0).

Load, prerender and discard

The navigation type of the first page view comes from these rules, in the sequence of web-vitals:

  1. prerender, if document.prerendering is true or activationStart is more than 0.
  2. restore, if document.wasDiscarded is true.
  3. The type of the Navigation Timing entry: navigate, reload or back-forward.
  4. navigate, if there is no applicable navigation entry.

For a prerendered page, the monitor waits for the prerenderingchange event. Then it makes its observers, and the observers get the earlier entries from the buffer. The load metrics count from activationStart.

Back/forward cache restores

A pageshow event with persisted ends the current page view and starts a new one with the type back-forward-cache. The start time is the timeStamp of the event. TTFB is 0 when the load had a navigation entry, as in web-vitals. FCP and LCP are the time from the pageshow event to the second animation frame after it. At that time, the browser painted the restored page.

The URL of the new page view is the URL of the document at the pageshow event (location.href), without the query string and the fragment. The page can change its URL after the load, for example with history.pushState(). Without a URL from the page source, the new page view gets the URL of the earlier page view.

Chromium restores a page with resume, visibilitychange and then pageshow. Thus the page is visible before pageshow. The lifecycle state machine gives a transition for each pageshow with persisted, also from a visible state. Thus the monitor starts the new page view in Chromium too. The entries from before the timeStamp of pageshow go to the page view that ends.

Soft navigations

Soft navigations are off by default (softNavigations: false). Then the values agree with CrUX, which does not divide a load at soft navigations. With softNavigations: true, each soft-navigation entry (Chromium 151 and later) ends the current page view and starts a new one:

  • The start time is the startTime of the entry: the start of the interaction that caused the navigation.
  • The URL is the URL of the entry, without the query string and the fragment.
  • TTFB is 0. FCP is presentationTime, or paintTime, minus the start time.
  • LCP comes from the interaction-contentful-paint entries of the same interaction: the render time of the largest paint minus the start time of the entry. The largest paint before the soft navigation (getLargestInteractionContentfulPaint()) counts at the start. The interaction-contentful-paint entries that the browser did not deliver yet come after it.
  • A trusted keydown or click after the start of the page view makes the LCP final, as for a load. The click that causes the navigation makes the LCP of the page view that ends final.
  • The interaction that caused the navigation is not a candidate of the new page view. Its entries that come before the soft-navigation entry in the sequence count in the page view that ends. The new page view ignores each entry with its interactionId, also when the browser delivers the entry after the soft-navigation entry. As in web-vitals, the INP of the new page view counts only the interactions after the soft navigation.
  • The entries that wait when the monitor processes the soft-navigation entry go to a page view by their start times. An entry that starts after the start of the navigation goes to the new page view, also when its observer did not deliver it yet.
  • An entry that the browser delivered before the soft-navigation entry stays in the page view that ends, also when it starts after the navigation. The web-vitals library does the same.

Checkpoints

At each checkpoint, the monitor gives the current values of the page view to the factory. These are the checkpoints:

  • The page changes to hidden, frozen or terminated. At terminated, the checkpoint is final.
  • A new page view starts. The checkpoint of the earlier page view is final.
  • stop() makes a final checkpoint, if the page view did not end yet.
  • flush() makes a checkpoint that is not final.

After its final checkpoint, a page view has ended. No report for it comes after that. For example, a flush() or a stop() after pagehide sends no second report. A checkpoint that is not final and has no values sends no report.

Show the diagram source
sequenceDiagram
  participant B as Browser
  participant L as Lifecycle
  participant V as PageViewVitals
  participant E as OTel SDK and exporter
  B->>L: visibilitychange (capture listener)
  L->>V: transition to hidden
  V->>V: process the entries that wait
  V->>E: record histogram values and events
  B->>E: visibilitychange (listener of the exporter)
  E->>V: onBeforeFlush: flush()
  E->>E: export
The lifecycle uses capture listeners on document, thus the checkpoint comes before the listener of the exporter. The flush hook makes the order unimportant, also for pagehide on window.

The sequence of the entries

The monitor processes the entries of all its observers in one sequence. At each delivery of the browser, it also takes the entries that the other observers did not deliver yet (takeRecords()). It does the same before each checkpoint. Then it merges the lists by startTime, with these rules:

  • The sequence of the browser stays in the list of each observer. For example, the last LCP candidate stays last, also when its startTime is earlier.
  • At equal start times, the observer that the monitor made first comes first: event, first-input, layout-shift, paint, largest-contentful-paint, soft-navigation, then interaction-contentful-paint.
  • A soft-navigation entry starts a new page view. The entries after it in the sequence go to the new page view.

Thus the entries that wait go to the correct page view, also when the browser gives the entries to the observers in a different sequence. The split at a soft navigation applies only to the entries that wait when the monitor processes the soft-navigation entry. An entry that the monitor processed before stays in the earlier page view.

The factory

The factory records the first value of each vital of each page view in the histogram of the vital, with the attribute navigation_type. A histogram cannot remove a value, thus later changes go only into events. The factory sends a browser.web_vital event for each value that is new or that changed since the previous report of the same page view. At a final checkpoint, it forgets the values of the page view. No report comes after the final checkpoint, thus the histogram records each vital of a page view one time.

Browser support

Vital or featureAPIChromiumFirefoxSafari
INPEvent Timing with interactionId9614426.2
performance.interactionCount14414426.2
first-input778926.2
CLSLayout Instability77not availablenot available
LCPlargest-contentful-paint7712226.2
FCPpaint608414.1
TTFBNavigation Timingyesyesyes
First hidden timevisibility-state entries115not availablenot available
PrerenderactivationStart, document.prerendering, prerenderingchange108not availablenot available
Soft navigationssoft-navigation, interaction-contentful-paint151not availablenot available
Back/forward cachepageshow with persistedyesyesyes

The versions come from browser support. The research gives no versions for Navigation Timing and pageshow. We found performance.interactionCount in Chromium 145, Firefox 146 and the WebKit build of Playwright (experiment E2).

The engines measure differently (browser support):

  • Chromium measures INP and LCP to the presentation of the frame. Firefox and Safari measure to the end of the render update, thus their values are slightly lower for the same work.
  • Safari gives high INP values in the tail. One cause is real: after an interaction, WebKit does not prioritize the render over tasks that are already in the queue (WebKit bug 319911).
  • Safari does not report LCP candidates in shadow DOM (WebKit bug 310264).
  • For a cross-origin image without Timing-Allow-Origin, the browsers give a coarsened render time: Chrome from 133, Firefox from 141 and Safari from 26.2.

Measurement validity

The monitor does not pause while the page is hidden, and it uses no SampleValidator. It applies the visibility rules of web-vitals instead:

  • The paint and largest-contentful-paint entries count only before the first hidden time, and only for the first page view. A page that loads in a background tab has no FCP and no LCP.
  • A restore and a soft navigation get FCP and LCP through their own rules.
  • INP and CLS ignore the entries from before the start of the page view.
  • For a prerendered page, the measurement starts at the activation.

Metrics

MetricKindUnitAttributesDescription
lag_web_vital_inp_histogramHistogrammsnavigation_typeInteraction to Next Paint (INP) for each page view.
lag_web_vital_cls_histogramHistogram1navigation_typeCumulative Layout Shift (CLS) for each page view, in browsers that have layout-shift entries.
lag_web_vital_lcp_histogramHistogrammsnavigation_typeLargest Contentful Paint (LCP) for each page view.
lag_web_vital_fcp_histogramHistogrammsnavigation_typeFirst Contentful Paint (FCP) for each page view.
lag_web_vital_ttfb_histogramHistogrammsnavigation_typeTime to First Byte (TTFB) for each page view. A restore from the back/forward cache and a soft navigation have no network response and get 0, as in web-vitals. A page without a navigation entry gets no value.

The count of a vital histogram is the number of page views with that vital. Thus a quantile of the histogram is a quantile over page views.

The web vital event

Each browser.web_vital event has these attributes. The names of the first six attributes agree with the OpenTelemetry semantic conventions v1.44, in which this event has the status Development (OpenTelemetry metrics in the browser). The conventions do not define the page view and the attribution, thus those attributes have the prefix lag..

AttributeValue
browser.web_vital.nameinp, cls, lcp, fcp or ttfb
browser.web_vital.valueThe current value of the vital.
browser.web_vital.deltaThe change since the previous event of this vital in this page view.
browser.web_vital.idThe page-view ID and the name, for example lag-1760000000000-4821733201941-inp.
browser.web_vital.ratinggood, needs-improvement or poor
browser.web_vital.navigation_typeThe navigation type of the page view.
lag.page_view.idThe ID of the page view.
lag.page_view.urlThe URL at the start of the page view, without the query string and the fragment.
lag.web_vital.*The attribution. INP: interaction_target, interaction_type, input_delay_ms, processing_duration_ms, presentation_delay_ms. CLS: largest_shift_target. LCP: target, url.

The latest event for each browser.web_vital.id has the final value. An unchanged value sends no event.

The time of an event is the time of the occurrence that gave the value, not the time of the report. The report usually comes when the page becomes hidden, possibly minutes later. These are the times:

VitalTime of the event
INPThe start of the first entry of the INP interaction.
CLSThe start of the largest shift of the worst session window.
LCP, FCP, TTFBThe start of the page view plus the value.

An INP estimate after a restore or a soft navigation has no time. Its event gets the time of the report. The rating uses the thresholds of web-vitals:

Vitalgood up topoor above
INP200 ms500 ms
CLS0.10.25
LCP2500 ms4000 ms
FCP1800 ms3000 ms
TTFB800 ms1800 ms

The page view event

Each page view also sends a lag.page_view.start event at its start: the load, a restore from the back/forward cache, or a soft navigation. Its attributes are navigation_type, lag.page_view.id, lag.page_view.url (when the view has a URL), and lag.page_view.previous_id for each view after the first. Thus a chart can show the start of each page view, and the IDs connect the views of one page. With a span sink, the event also has lag.page_view.trace_id and lag.page_view.span_id, the identity of the sampled span of the view. A dashboard can then link the view to its trace (spans for the periods).

Configuration

function createInstrumentedPageViewVitals(
    deps : CoreDeps & ObserverDeps & Partial<PerformanceDeps> & Partial<AbsoluteClockDeps> & Partial<EventDeps>
        & Partial<Pick<FrameDeps, "requestAnimationFrame">> & Partial<PageDeps>
        & Partial<Pick<LifecycleDeps, "window">>,
    lifecycle : LifecycleStateMachine,
) : MonitorHandle<PageViewVitals>;

The factory uses CoreDeps (logger, clock, meter), ObserverDeps (PerformanceObserver) and a lifecycle state machine. The other groups are optional:

DependencyDefaultWhat it does
performancenoneGives performance.interactionCount to the INP calculator, and the times of the events when there is no absoluteClock.
absoluteClocknoneThe clock of the times of the events. setupAllMonitors() gives one clock for the page.
eventsnoneThe event sink of the browser.web_vital and lag.page_view.start events.
requestAnimationFramenoneMeasures FCP and LCP after a restore. Without it, a restored page view has no FCP and LCP.
pagenoneThe navigation entry, the prerender and discard state, the hidden times and the URL of the document (createPageSource(document, performance)). Without it, the first page view has the type navigate and no TTFB.
describeNodethe selector of web-vitalsMakes the CSS selector of a DOM node for the attribution.
softNavigationsfalseWhen true, each soft navigation starts a new page view.
windownoneThe target of the trusted keydown and click events that make the LCP final. Without it, only the Event Timing entries of the inputs make the LCP final.

setupAllMonitors() starts the monitor when it has PerformanceObserver and the lifecycle. It gives the monitor the window of the lifecycle dependencies. createBrowserDeps() gives page and the option softNavigations.

import { metrics } from "@opentelemetry/api";
import { logs } from "@opentelemetry/api-logs";
import {
    createBrowserDeps,
    createInstrumentedLifecycle,
    createInstrumentedPageViewVitals,
    createOtelEventSink,
} from "@mark1russell7/lag";

const deps = createBrowserDeps(window, {
    logger : console,
    meter : metrics.getMeter("lag"),
    events : createOtelEventSink(logs.getLogger("lag-events")),
    softNavigations : true,
});
const lifecycle = createInstrumentedLifecycle(deps);
if (lifecycle.monitor && deps.PerformanceObserver) {
    const vitals = createInstrumentedPageViewVitals(
        { ...deps, PerformanceObserver : deps.PerformanceObserver },
        lifecycle.monitor,
    );
    vitals.monitor?.subscribe((view) => console.log(`A new page view: ${view.id} (${view.navigationType})`));
    // Before an exporter flushes: record the current values
    vitals.monitor?.flush();
}

The flush hook

Connect flush() to the exporter, so that the last export of a page has the final values. With setupAllMonitors(), use AllMonitorHandles.flush(), which uses PageViewVitals.flush(). With otel-ts, write otel.onBeforeFlush(() => monitors.flush()).

WarningListener order

Chromium does not start the capture listeners of window first. For pagehide on window, it starts the listener that the page added first. An exporter that added its pagehide listener before the monitors can flush before the final checkpoint. The flush hook prevents this. Refer to page lifecycle.

Cost

  • Observers: five PerformanceObserver instances, or seven with soft navigations. Only the types that the browser has get an observer.
  • Records: at most five histogram values for each page view.
  • Events: one event for each change of a vital at a checkpoint.
  • Memory: for each page view, the 10 longest interactions with at most 16 entries each. The collector keeps the CSS selector of each target, also of each layout shift, not the DOM node. It also keeps the groups of the last 50 frames, with three times each.
  • Restores: two animation frame callbacks for each restore.

Limits

  • The histogram has the first value. Usually the first report comes when the page becomes hidden for the first time. A later change, for example a slower interaction after the user comes back, goes only into the events.
  • The frames of the INP attribution. The monitor asks for events of 16 ms or more, and web-vitals asks for 40 ms or more by default. Thus a frame here can also contain shorter events. With a durationThreshold of 16 ms, web-vitals gives the same three phases (web-vitals-oracle.test.ts). The attribution has no LoAF fields.
  • More INP candidates than web-vitals. The monitor asks for events of 16 ms or more. web-vitals uses 40 ms. Thus INP can differ from web-vitals when the INP interaction is shorter than 40 ms.
  • No CLS in Firefox and Safari. These browsers have no layout-shift entries, thus the monitor gives no CLS there, as web-vitals does. The CLS histogram has only the page views of browsers with layout-shift entries.
  • TTFB of 0 for restores and soft navigations. These page views record a TTFB of 0, as in web-vitals. Use the attribute navigation_type to separate them from loads. A page without a navigation entry gets no TTFB.
  • The count without interactionCount. Fast interactions give no entry, thus the count is too low, and INP can be slightly too high on a page with many fast interactions.
  • Soft navigations only in Chromium 151 and later. In other browsers, the option has no effect.
  • A late entry of the navigation interaction. If the browser delivers an entry of the interaction that caused a soft navigation after the soft-navigation entry, the entry counts in no page view. The earlier page view has its final report already, and the new page view ignores the entry. In Chrome 153, the browser delivered the entries of the click first, then the interaction-contentful-paint entry, and then the soft-navigation entry (soft-navigation.test.ts). Thus we did not see this case in a browser.

Tests

Unit tests:

  • vitals/PageViewVitals.test.ts, a load: the five vitals at the first hide, and the event threshold of 16 ms. The last LCP candidate of the browser stays, also when its start time is earlier. The interactions of the load count from the start of the page. The final values at terminated, and no paints after the first hide. The first hidden time from the visibility-state entries, and the types restore and navigate. The buffered FCP and layout shifts count, also when observe() delivers them at once, as in old Safari.
  • vitals/PageViewVitals.test.ts, LCP finalization: a click or a key press makes the LCP final. This occurs through the entry of the input or through the DOM event, also for a fast click without an entry. A synthetic click does not, and stop() removes the listeners.
  • vitals/PageViewVitals.test.ts, prerender and restores: a prerendered page starts at the activation and measures from it. A restore ends the page view and starts a new one, with TTFB 0 and the paint times from two animation frames. The restored page view has the URL of the document at the restore. The new page view also starts with the event sequence of Chromium. Without entries, the INP of a restore is 8 ms.
  • vitals/PageViewVitals.test.ts, soft navigations: they are off by default, start a new page view, and operate only when the browser has the two entry types. The paints and the interactions that the browser did not deliver yet go to the correct page view. An entry that the browser delivered before the soft-navigation entry stays in the earlier page view. A click makes the LCP of the navigation final.
  • vitals/PageViewVitals.test.ts, checkpoints: the checkpoint processes the entries that wait, and divides them at a soft navigation in the sequence of their start times. flush() makes a checkpoint that is not final. stop() makes a final checkpoint and stops the observers. A vital whose entry type the browser does not have gets no value, also after a restore.
  • vitals/ViewCollector.test.ts: the rules of INP, with an INP of 0 for a first input of 0 ms, and the count of the load from 0. The interaction that started a soft navigation stays out of the new page view, also when its entry comes late. The attribution: the target, the type, the three phases, the cap at the next paint, and the events of the frame, also of other events. The processing starts at the interaction at the earliest. CLS after FCP, and no value below 0. The CLS target is the first source with an element node, with the selector from the time of the shift.
  • instrumented/page-view-vitals.test.ts: the histograms record each vital of a page view one time. This is also true when a flush() or a stop() comes after the final report. The deltas of the events add up to the value.
  • instrumented/page-view-vitals.test.ts, the times: each vital event has the time of its occurrence, not of the report. The load and a restore each send a lag.page_view.start event at the start of the view, and the restore names the view before it.
  • vitals/ViewCollector.test.ts, the times: the time of each value, also in a view that starts later. No time for an INP estimate.
  • vitals/selector.test.ts and browser/page-source.test.ts: the selector of web-vitals, and the navigation entry, the hidden times, the prerender state and the URL of the document.
  • setup-all-monitors.test.ts: flush() records the vitals that wait. Each vital records one time for each page view, and the events have the names of the semantic conventions. An unchanged value sends no second event.

Browser tests (Vitest browser mode with Playwright, in Chromium, Firefox, WebKit and Chrome, unless an item names other browsers):

  • lag-monitors.test.ts (only where the browser has Event Timing with interactionId): on the test page, TTFB is 0 or more, and FCP is more than 0. LCP is not less than FCP. A real click on a button whose handler blocks for 120 ms gives an INP of 119 ms or more (a margin of 1 ms for the clock of WebKit). Its attribution has the target #slow-button, the type pointer and a processing phase of 110 ms or more.
  • web-vitals-oracle.test.ts: web-vitals 6.2.3 and the monitor observe the same page, both with a durationThreshold of 16 ms. FCP, LCP and TTFB are exactly the same. CLS is exactly the same where the browser has layout-shift entries, and the two libraries give no CLS elsewhere.
  • web-vitals-oracle.test.ts, INP (only with Event Timing and interactionId): real clicks on buttons that block for 40, 100 and 180 ms give exactly the same INP in the two libraries. The target, the type and the three phases of the attribution also agree.
  • browser-apis.test.ts (only where the browser has visibility-state entries: Chromium and Chrome): the first entry is visible at the time 0. The page source of a page that was not hidden gives no hidden times.
  • soft-navigation.test.ts (only where the browser has soft-navigation entries: Chrome 153): a server command opens a top-level page, because Chrome detects soft navigations only in the top-level frame. A real click blocks for 60 ms, changes the URL and adds the content of the next page. The click gives the INP of the first page view (88 ms in one run), with the target #go. The second page view has the type soft-navigation, the new URL, an LCP from its interaction-contentful-paint entry (93 ms), a TTFB of 0 and no INP.
  • cdp/vitals-checkpoint.test.ts (Chromium in the new headless mode): when the page becomes hidden, TTFB, FCP, LCP and CLS each record one histogram value, with the type navigate or reload. Each browser.web_vital event has the ID of the page view. A second hide records no second FCP value and sends no new event.

A CI job also operates the browser test files in Safari 26.6.2 on a GitHub macOS runner, through safaridriver. In the log of that job, the oracle agrees exactly in Safari:

Vitalweb-vitals 6.2.3PageViewVitals
FCP400 ms400 ms
LCP400 ms400 ms
TTFB22 ms22 ms
INP184 ms184 ms
INP phases: input delay, processing, presentation0 ms, 180 ms, 4 ms0 ms, 180 ms, 4 ms

Safari has no layout-shift entries, thus neither library gives a CLS there. lag-monitors.test.ts also passes in that job.

Source

lag: Main-thread responsiveness monitoring for browser apps, exported as OpenTelemetry metrics.

To change a page, edit its file in packages/site/content/. The writing style guide tells you how.

An AI model (Claude, from Anthropic) wrote most of the text and the code of this site and of the library, under the direction of the author. The tests and the STE linter examine them. The writing standard gives the reason for this note.