Sending Web Vitals to Datadog RUM
This how-to is part of the Integrating Performance Budgets With Datadog reference, and it covers exactly one job: getting real Core Web Vitals values out of the browser and into Datadog RUM in a shape you can slice by route and device. Datadog's own RUM instrumentation already captures a version of LCP, INP, and CLS, but the values are averaged into its automatic view timings and are hard to reconcile against a budget you defined elsewhere. If you want the same numbers your Core Web Vitals Budget Allocation ceilings are written against — the ones Google's field tooling reports at the 75th percentile — you forward them yourself from the canonical web-vitals library.
The plan is straightforward: install the @datadog/browser-rum SDK, attach it to the web-vitals callbacks, and emit each finalized metric as a custom action with a numeric context so it becomes queryable. We tag every emission with the route pattern and device class, sample the stream so ingest stays affordable, and then confirm the metric shows up in the RUM Explorer before we trust a single dashboard tile built on it. If you have already read Injecting Custom Metrics via PerformanceObserver, this is the Datadog-flavored destination for that same beacon data.
Why route Web Vitals through Datadog RUM
The values Datadog computes automatically are useful for triage, but they are not the values your budgets are written against. web-vitals reports LCP, INP, and CLS using the exact attribution model and finalization timing that Chrome's field data uses, so a 2500 ms LCP ceiling at P75 on mid-range mobile over Fast 3G means the same thing in your Datadog dashboard as it does in the Chrome UX Report. Forwarding the library's own numbers keeps that alignment intact and lets you compare synthetic gate results against field reality without a translation table.
The second reason is granularity. web-vitals gives you the metric id, the rating, and the attribution object — the specific element or event responsible for the score. When you push that whole payload into RUM as structured context, you can later answer "which route regressed INP on Android over 4G at P90" instead of just "INP went up." That segmentation is the entire point of routing your own numbers rather than accepting the SDK's rollup.
Installing and initializing the datadog-rum SDK
Add both libraries to your app. The @datadog/browser-rum package is the transport; web-vitals is the source of truth for the metric values. Initialize RUM once, as early as possible in your entry bundle, before the first paint so the SDK can attach its own listeners and correlate your custom actions with the session and view.
import { datadogRum } from '@datadog/browser-rum';
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
datadogRum.init({
applicationId: import.meta.env.VITE_DD_APP_ID,
clientToken: import.meta.env.VITE_DD_CLIENT_TOKEN,
site: 'datadoghq.com',
service: 'storefront-web',
env: import.meta.env.VITE_DEPLOY_ENV,
version: import.meta.env.VITE_RELEASE_SHA,
sessionSampleRate: 100,
sessionReplaySampleRate: 0,
trackResources: false,
trackLongTasks: false,
defaultPrivacyLevel: 'mask-user-input',
});
Two choices here matter for budgets. Setting version to the release SHA lets you split any vital by deploy, which is what Correlating Deploys With Datadog Events builds on. And keeping sessionSampleRate at 100 here is deliberate — we sample the vital emissions ourselves, further down, rather than dropping whole sessions, because a dropped session also drops its errors and traces. Import from web-vitals/attribution rather than the plain entry point so each callback carries the attribution object that names the offending element or event target.
Forwarding web-vitals as custom actions
The SDK exposes addAction(name, context), which records a named custom action with an arbitrary structured context. That context is where the numeric value lives, and numeric context fields are what Datadog lets you aggregate — average, percentile, max — in the RUM Explorer. Register one handler and route all three vitals through it so tagging and sampling stay in one place.
function reportVital(metric) {
datadogRum.addAction(`web-vital.${metric.name.toLowerCase()}`, {
vital: {
name: metric.name,
value: metric.value,
rating: metric.rating,
metric_id: metric.id,
navigation_type: metric.navigationType,
target: metric.attribution?.interactionTarget
|| metric.attribution?.largestShiftTarget
|| metric.attribution?.element
|| 'unknown',
},
});
}
onLCP(reportVital);
onINP(reportVital);
onCLS(reportVital);
Each callback fires once with the final value: onLCP at the first user interaction or tab hide, onINP on page hide after the worst interaction is known, and onCLS when the layout-shift total is sealed. Because value is a plain number inside vital.value, Datadog indexes @vital.value as a measure. CLS ships as a unitless score (multiply by 1000 if you prefer integer storage), while LCP and INP ship in milliseconds. Do not round in the browser — send the raw value and let the query apply the percentile, so a 2500 ms LCP budget at P75 on mid-range mobile over Fast 3G stays exact.
Tagging by route and device
A raw stream of vitals with no dimensions is nearly useless — you cannot tell a slow checkout route from a fast marketing page. Add a global context to the SDK so every action, error, and view inherits the route pattern and device class without you passing it on each call. Use the route pattern (/product/:id), never the concrete URL, or high-cardinality paths will shatter your aggregates. This is the same segmentation discipline that Mobile vs Desktop Budget Divergence argues for at budget-definition time.
function deviceClass() {
const mem = navigator.deviceMemory || 8;
const cores = navigator.hardwareConcurrency || 8;
if (mem <= 2 || cores <= 4) return 'low-end-mobile';
if (mem <= 4) return 'mid-range-mobile';
return 'desktop-or-high-end';
}
datadogRum.setGlobalContextProperty('device_class', deviceClass());
datadogRum.setGlobalContextProperty('effective_connection',
navigator.connection?.effectiveType || 'unknown');
// call on every client-side route change
export function onRouteChange(routePattern) {
datadogRum.setGlobalContextProperty('route_pattern', routePattern);
}
With route_pattern, device_class, and effective_connection attached, a single RUM query resolves to a budget-comparable segment. The table below shows the segments a typical storefront tracks and the LCP ceiling each carries; every ceiling is a P75 target measured over the stated device and connection, because a P75 desktop number and a P75 low-end-mobile number are not interchangeable.
| Route pattern | Device class | Connection | LCP P75 ceiling (ms) |
|---|---|---|---|
| /product/:id | mid-range-mobile | Fast 3G | 2500 |
| /product/:id | desktop-or-high-end | Cable | 1800 |
| /checkout | low-end-mobile | Slow 4G | 3000 |
| /search | mid-range-mobile | Fast 3G | 2700 |
| / (home) | desktop-or-high-end | Cable | 1600 |
Sampling to control ingest cost
Datadog bills by RUM session and by the volume of custom actions, so emitting three vitals for every one of a few million daily sessions gets expensive fast. Because we kept sessionSampleRate at 100 for error and trace fidelity, we throttle at the emission layer instead. Draw one random number per page load and gate all three vitals on it, so a sampled-in page contributes a complete LCP, INP, and CLS set rather than a partial one — partial sets skew per-segment percentiles. The trade-offs of where to sample are covered in depth in RUM Sampling Strategies for High-Traffic Sites.
const VITAL_SAMPLE_RATE = 0.2; // keep 20% of page loads
const sampledIn = Math.random() < VITAL_SAMPLE_RATE;
function reportVital(metric) {
if (!sampledIn) return;
datadogRum.addAction(`web-vital.${metric.name.toLowerCase()}`, {
vital: { name: metric.name, value: metric.value, rating: metric.rating },
sample_rate: VITAL_SAMPLE_RATE,
});
}
Storing sample_rate in the context lets you reconstruct true traffic volume later — a count at 20% sampling represents five times the sessions. The sample rate you can afford depends on segment volume, not total volume: a low-end-mobile checkout segment might only see a few thousand loads a day, and at 20% you would have too few points to trust a P90. Set a higher rate for thin segments or route their vitals unsampled, and reserve aggressive sampling for high-volume routes where even 5% clears the ~1000-sample floor a stable P75 needs.
Verifying the metric in Datadog
Do not build a dashboard on data you have not confirmed. Open the RUM Explorer, filter to @action.name:web-vital.lcp, and add a facet on @context.vital.value. If the events appear with numeric values and your route_pattern and device_class tags populate, the pipeline works end to end. Promote @context.vital.value to a measure so it becomes aggregatable, then check a P75 query resolves to a sane millisecond number rather than null.
curl -s -X POST "https://api.datadoghq.com/api/v2/rum/events/search" \
-H "DD-API-KEY: ${DD_API_KEY}" \
-H "DD-APPLICATION-KEY: ${DD_APP_KEY}" \
-H "Content-Type: application/json" \
-d '{
"filter": {
"query": "@action.name:web-vital.lcp @context.route_pattern:\"/product/:id\"",
"from": "now-1h",
"to": "now"
},
"page": { "limit": 5 }
}' | jq '.data[].attributes.attributes.context.vital.value'
If the query returns an empty array, the usual causes are: the action name was capitalized differently than your filter, the SDK initialized after the vitals fired (import order), or an ad blocker dropped the intake request in your own browser — test in a clean profile. Once values flow, a P75 aggregation over the last day should land near your budget; if a 2500 ms mid-range-mobile Fast 3G LCP budget is reading 4200 ms at P75, that is a real regression to route into Datadog Monitors for Budget Regressions, not a pipeline bug. Cross-check the percentile choice itself against Using 75th Percentile for Real-World INP Targets so your monitor and your budget agree on the same statistic.
Frequently Asked Questions
Why not just use Datadog's built-in Core Web Vitals instead of forwarding web-vitals?
Datadog's automatic vitals use its own view-timing model, which can differ from the finalization timing Chrome's field data uses. Forwarding the canonical web-vitals values keeps your dashboard aligned with the same numbers your P75 budgets and the Chrome UX Report are written against, and it gives you the attribution object for root-cause segmentation.
Should I send vitals as custom actions or as Datadog custom metrics?
Send them as RUM custom actions with the value in numeric context. That keeps each vital tied to its session, view, route, and device tags so you can slice by segment. Reserve true custom metrics for pre-aggregated server-side rollups where per-session context is not needed.
How high should I set the sample rate?
Base it on your thinnest tracked segment, not total traffic. A stable P75 needs roughly a thousand samples, so a low-volume checkout-on-low-end-mobile segment may need 50 to 100 percent retention while a high-volume home route stays trustworthy at 5 percent. Store the sample rate in context so you can reconstruct true counts.
Why does CLS look wrong compared to Lighthouse?
CLS from web-vitals is a field value that seals at page hide and reflects real user scrolling and interaction, whereas Lighthouse reports a synthetic load-time snapshot. They measure different things; compare field CLS at P75 against your field budget, not against a lab score.
Do I need to call setGlobalContextProperty on every route change?
Yes for single-page apps. The route pattern only updates when you tell the SDK, so hook setGlobalContextProperty into your router's navigation event. Device class and effective connection can be set once at init since they rarely change within a session.