docs/oss/building-features/web-vitals-and-rum.md
React on Rails instruments server-side render performance — the repo's
benchmarks/ suite feeds a Bencher-based regression dashboard, and
Performance Benchmarks covers
ExecJS vs Node Renderer throughput. What that does not tell you is how your app
performs for real users in real browsers: Largest Contentful Paint (LCP),
Cumulative Layout Shift (CLS), Interaction to Next Paint (INP), Time to First
Byte (TTFB), and First Contentful Paint (FCP). This page closes that loop with a
first-party Real User Monitoring setup: the browser collects Web Vitals with
the web-vitals library and
beacons them to your own Rails endpoint — no third-party analytics vendor,
no Vercel, no extra SaaS bill, and the data never leaves your app
(privacy/GDPR friendly).
For comparison: Next.js ships
useReportWebVitals,
a thin hook over the same web-vitals library, and pairs it with Vercel Speed
Insights for turnkey field RUM — a path that effectively assumes you deploy on
Vercel and send your traffic data there. On Rails, the equivalent is just
another controller action: POST the vitals to your app and aggregate them with
ActiveRecord or your existing APM.
Scope note: This page covers wiring the framework-agnostic
web-vitalsmetrics. Framework-level hydration-timing instrumentation (performance.markentries emitted from the React on Rails client runtime — "time to hydrate", "first interaction after hydration") is a planned follow-up, tracked in issue #3877. It has not shipped yet; nothing on this page depends on it.
web-vitals, queues them, and flushes the queue with
navigator.sendBeacon when the page is hidden or unloaded.Add the web-vitals package:
npm install web-vitals
# or: yarn add web-vitals
# or: pnpm add web-vitals
Then add the following to a Shakapacker entry — either a dedicated pack or your existing client bundle entry. It is intentionally plain JavaScript with no React on Rails API dependency, so it works in any client bundle:
// app/javascript/packs/web-vitals-reporter.js
// (or import it from your existing client bundle entry)
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
const VITALS_ENDPOINT = '/web_vitals';
// Report only a fraction of page views. Decide once per page load so a sampled
// page view reports its full set of metrics (you can also read the rate from a
// <meta> tag to configure it from Rails without rebuilding the bundle).
const SAMPLE_RATE = 0.1; // 10%
const isSampled = Math.random() < SAMPLE_RATE;
const queue = [];
function addToQueue({ name, value, id, rating, delta, navigationType }) {
queue.push({
name,
value,
id,
rating,
delta,
navigation_type: navigationType,
// Capture the path when the metric is observed, not when the queue is
// flushed -- with Turbo Drive one flush can carry metrics from several
// routes. Send the pathname only: query strings can contain tokens or PII.
page: window.location.pathname,
});
}
function flushQueue() {
if (!isSampled || queue.length === 0) {
return;
}
// splice(0) drains the queue and returns its contents in one step.
const body = JSON.stringify({ metrics: queue.splice(0) });
// sendBeacon queues delivery even while the page unloads. Wrap the payload in
// a Blob so the request carries a JSON content type and Rails parses the
// params (a bare string would be sent as text/plain).
const blob = new Blob([body], { type: 'application/json' });
if (!(navigator.sendBeacon && navigator.sendBeacon(VITALS_ENDPOINT, blob))) {
// Fallback for browsers without sendBeacon: keepalive lets the request
// outlive the page. Telemetry is fire-and-forget -- swallow delivery
// errors instead of surfacing them.
fetch(VITALS_ENDPOINT, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {});
}
}
onCLS(addToQueue);
onFCP(addToQueue);
onINP(addToQueue);
onLCP(addToQueue);
onTTFB(addToQueue);
// CLS, LCP, and INP only settle when the page is hidden or unloaded, so flush
// on visibility change rather than on a timer.
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flushQueue();
}
});
// pagehide covers older Safari, which does not reliably fire visibilitychange
// on unload.
addEventListener('pagehide', flushQueue);
// Turbo Drive swaps the page without firing visibilitychange or pagehide, so
// also flush before each Turbo visit. Harmless no-op in apps without Turbo.
addEventListener('turbo:before-visit', flushQueue);
Why this shape:
visibilitychange/pagehide — CLS, LCP, and INP are
not final until the user leaves the page, so per-metric immediate POSTs would
both undercount and multiply requests. Batching into one beacon per page view
is the pattern the web-vitals documentation recommends.navigator.sendBeacon — a normal fetch/XHR issued during unload is
routinely cancelled by the browser; sendBeacon hands the payload to the
browser to deliver asynchronously even after the page is gone. The
keepalive: true fetch is the fallback for the rare browser without it.<body> without firing
visibilitychange or pagehide, so several routes can share one flush.
That is why the snippet stamps each metric with the pathname at
observation time (one page per metric, not per POST) and also flushes on
turbo:before-visit. Registering that listener in a non-Turbo app is a
harmless no-op — the event simply never fires.web-vitals treats a
bfcache restore as a separate page view
and reports all metrics again with new ids (and
navigationType: 'back-forward-cache'). The listeners and queue survive the
restore, so those metrics are delivered on the next hide — no extra code
needed.web-vitals
Metric type: name
('CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB'), value, id (unique per metric
per page load — use it to deduplicate), rating
('good' | 'needs-improvement' | 'poor'), delta (change since the last
report of this metric), and navigationType (e.g. 'navigate',
'reload', 'back-forward', 'prerender') — plus the page pathname the
snippet captures per metric.Add a route:
# config/routes.rb
post "/web_vitals", to: "web_vitals#create"
And a controller:
# app/controllers/web_vitals_controller.rb
class WebVitalsController < ApplicationController
# sendBeacon cannot set custom request headers, so the beacon arrives without
# the X-CSRF-Token header Rails expects. Skipping forgery protection on this
# one write-only, anonymous endpoint is the standard trade-off -- see the
# CSRF section below before copying this.
skip_forgery_protection
VALID_NAMES = %w[CLS FCP INP LCP TTFB].freeze
VALID_RATINGS = %w[good needs-improvement poor].freeze
def create
rows = coerced_rows
return head :unprocessable_entity if rows.nil?
# insert_all! (Rails 6+) writes every row in one all-or-nothing statement,
# skipping per-row callbacks/validations -- fine for telemetry. On older
# Rails, wrap per-row creates in a single transaction instead.
WebVitalMetric.insert_all!(rows) if rows.any?
head :no_content
end
private
# Coerce and validate every metric before persisting anything. Metrics with
# an unknown name or rating are silently dropped (skipped, not an error);
# any non-numeric value rejects the whole beacon by returning nil. Either
# way, nothing half-persists. String fields are length-clamped because this
# endpoint skips CSRF protection -- never trust client-supplied sizes.
def coerced_rows
now = Time.current
vitals_params[:metrics].to_a.filter_map do |metric|
next unless VALID_NAMES.include?(metric[:name])
next unless VALID_RATINGS.include?(metric[:rating])
{
name: metric[:name],
value: Float(metric[:value]),
delta: Float(metric[:delta]),
metric_id: metric[:id].to_s.byteslice(0, 64),
rating: metric[:rating],
navigation_type: metric[:navigation_type].to_s.byteslice(0, 32),
page: metric[:page].to_s.byteslice(0, 255),
created_at: now
}
end
rescue ArgumentError, TypeError
# Float() raises on non-numeric values.
nil
end
def vitals_params
params.permit(metrics: %i[name value id rating delta navigation_type page])
end
end
The strong-params schema matches the client payload (and the web-vitals
Metric type): name, value, id, rating, delta, navigation_type,
plus the per-metric page path. Everything else in the request is dropped, and
all metrics are coerced up front and written in a single atomic insert — a
malformed beacon never half-persists.
skip_forgery_protection is what makes the sendBeacon path work, and it is a
real trade-off:
POST /web_vitals per IP). Treat the data as untrusted telemetry, not as an
audit log.sendBeacon entirely and
always use fetch(..., { keepalive: true }) with the X-CSRF-Token header
read from the csrf-token meta tag (emitted by csrf_meta_tags). keepalive requests survive unload in
modern browsers, though delivery is somewhat less reliable than sendBeacon
(keepalive requests share a small per-origin in-flight budget). Pick this if
your security posture rules out any unprotected endpoint.The SAMPLE_RATE constant in the client snippet controls what fraction of page
views report. 10% is a sensible default for most apps: Core Web Vitals are
percentile statistics, so you need volume, not completeness. Guidance:
1) — you
need every data point to compute a stable p75.<meta name="web-vitals-sample-rate" content="<%= ENV.fetch('WEB_VITALS_SAMPLE_RATE', '0.1') %>">)
and read it in the snippet instead of hardcoding the constant.This is guidance, not a product — storage and visualization are your app's choice. Two common paths:
# Generate with `bin/rails generate migration CreateWebVitalMetrics` so the
# migration version matches your app's Rails version.
class CreateWebVitalMetrics < ActiveRecord::Migration[7.1]
def change
create_table :web_vital_metrics do |t|
t.string :name, null: false
t.float :value, null: false
t.float :delta
t.string :metric_id
t.string :rating
t.string :navigation_type
t.string :page
t.datetime :created_at, null: false
end
add_index :web_vital_metrics, %i[name created_at]
add_index :web_vital_metrics, %i[page name]
end
end
Core Web Vitals thresholds are defined at the 75th percentile, so query p75 rather than averages (averages hide the slow tail that ratings are based on):
-- p75 LCP per page over the last 7 days (PostgreSQL)
SELECT page,
percentile_cont(0.75) WITHIN GROUP (ORDER BY value) AS p75_lcp_ms,
count(*) AS samples
FROM web_vital_metrics
WHERE name = 'LCP'
AND created_at > now() - interval '7 days'
GROUP BY page
ORDER BY p75_lcp_ms DESC;
percentile_cont is PostgreSQL-specific — on MySQL or SQLite, compute the
percentile with a window function (PERCENT_RANK()/NTILE) or load the sorted
values and pick the p75 index in Ruby.
Prune old rows on a schedule (a nightly
WebVitalMetric.where("created_at < ?", 90.days.ago).delete_all job) or roll
daily percentiles up into a summary table if volume grows.
If you already run an APM or metrics stack (New Relic, Datadog, Sentry,
Prometheus/StatsD, Scout, Skylight…), skip the table and forward from the
controller instead — e.g. emit a custom event or a distribution/histogram
metric tagged with name, rating, and page. You keep the same first-party
beacon and endpoint; only the sink changes. This is usually the right call when
the APM already owns your dashboards and alerting.
This setup is deliberately privacy-friendly, but keep it that way:
location.pathname precisely because query strings can carry tokens or
personal data. If a path itself embeds an identifier (e.g. /users/123),
normalize it before storing (/users/:id).navigation_type, rating, page path) answer almost every
performance question.The benchmarks/ suite and Performance
Benchmarks answer "did this change
make server rendering slower?" under controlled load. Field Web Vitals answer
"what do real users experience?" across real devices, networks, and cache
states — including everything the server-side numbers cannot see: asset
delivery, font loading (see Font Optimization, which targets LCP
and CLS directly), hydration cost, and third-party scripts. Use both: Bencher
to catch server-render regressions in CI, and your vitals table to verify that
optimizations actually move p75 LCP/CLS/INP for users.