Spoar

speedInsights

Real-user Core Web Vitals from the web-vitals attribution build.

import { speedInsights, speedProps } from "@spoar/sdk/plugins";
import type { SpeedOptions } from "@spoar/sdk/plugins";
import { createAnalytics } from "@spoar/sdk";
import { speedInsights } from "@spoar/sdk/plugins";

export const analytics = createAnalytics({
  project: "example.com",
  key: "pk_...",
  endpoint: "/_ra",
  plugins: [speedInsights({ sampleRate: 0.5 })],
});

Options

SpeedOptions, all optional:

OptionTypeDefaultDoes
sampleRatenumber1Share of page loads that measure, from 0 to 1. Decided once per page load
random() => numberMath.randomThe number compared with sampleRate; a page load measures when it is below the rate
load() => Promise<Vitals>() => import("web-vitals/attribution")Loads the onLCP, onINP, onCLS, onFCP and onTTFB functions

random and load exist for tests; apps set sampleRate only.

Sends

One web_vital event per metric:

PropTypeValue
metricstringlcp, inp, cls, fcp or ttfb
idstringThe web-vitals metric id, so a later report of the same metric replaces the earlier one
valuenumberCLS rounded to 4 decimals, the others rounded to whole milliseconds
ratingstringgood, needs-improvement or poor, from web-vitals
navigationTypestringThe web-vitals navigation type, such as navigate, reload or back-forward
connectionstring or nullnavigator.connection.effectiveType, such as 4g, or null where the browser does not expose it
selectorstring or nullThe attributed element: the LCP target, the INP interaction target or the largest CLS shift target; null for FCP and TTFB
sampleRatenumberThe sampleRate in effect
routestringThe route template the page had when its first metric arrived, so metrics sent after a client-side navigation still belong to the page they measured

Behaviour

  • The plugin measures the hard navigation only. Every metric is credited to the path the page loaded with, even when it is sent after a client-side navigation.
  • web-vitals/attribution is loaded with a dynamic import, and only on page loads that are sampled in.
  • LCP, INP and CLS are reported on every change. The buffer keeps the latest value per metric id, so the final values are ready even when the page unloads without a visibility change.
  • Buffered metrics are sent together when the tab is hidden, when the next pageview happens, or when 6 metrics are waiting.

speedProps

speedProps(metric, sampleRate, connection) is the function the plugin uses to turn a web-vitals metric into the props above, without route. It is exported for custom reporting:

import { onLCP } from "web-vitals/attribution";
import { speedProps } from "@spoar/sdk/plugins";
import { analytics } from "./analytics";

onLCP((metric) => analytics.track("web_vital", speedProps(metric, 1, null)));
ParameterTypeDoes
metricAn LCP, INP, CLS, FCP or TTFB metric with attributionThe measured metric
sampleRatenumberWritten to the sampleRate prop
connectionstring or nullWritten to the connection prop

In reports

The API stores a web_vital event as a speed row unless it comes from bot, internal or localhost traffic, names an unknown metric or rating, or holds an impossible value (negative, CLS above 10, a timing above 120 seconds). The row is keyed by the project and the metric id, so updates of INP and CLS replace earlier ones. Tablets count as mobile. Rows from preview deployments are kept and marked is_preview; the speed reads show production unless they ask for environment=preview or environment=all.

The SQL views web_vitals (one row per metric) and daily_vitals (daily percentiles) expose them. The rollup job keeps daily percentiles and rating counts per route, device and metric in rollup_vitals, and drops raw speed rows older than 30 days. The cleanup job drops them sooner when the project's retentionDays is shorter. The speed reads use the rollup for those older days, without the page, country and selector it does not keep.

On this page