Contents
Overview
This tutorial explains, in full detail, how to measure Web Vitals in JavaScript on a WordPress site and reliably send those metrics to a WordPress REST API endpoint for storage, analysis, or forwarding to an analytics backend. It covers recommended client-side measurement methods, robust data delivery (sendBeacon, fetch fallback, offline queuing), how to enqueue and localize scripts in WordPress, how to register and secure a REST endpoint in PHP, and storage patterns (custom DB table, post meta, custom post type). It also covers privacy, security, testing, and scaling considerations.
What are Web Vitals (short primer)
- LCP (Largest Contentful Paint): measures perceived load speed — marks when the largest content element becomes visible.
- FID (First Input Delay) / INP (Interaction to Next Paint): measures responsiveness. FID is event-based INP is the newer, more comprehensive metric. Use INP if available.
- CLS (Cumulative Layout Shift): measures visual stability, how much layout shifts unexpectedly.
- FCP (First Contentful Paint): time until the browser renders the first bit of content from the DOM.
- TTFB (Time to First Byte): server response latency measured from navigation start to receipt of first byte.
High-level approach
- Measure metrics in the browser using the web-vitals library (recommended) or the native Performance API.
- Send each metric to a WordPress REST API endpoint using navigator.sendBeacon where possible, falling back to fetch. Buffer metrics offline and retry.
- Secure the endpoint (nonce, capability checks, rate-limiting), sanitize inputs, and store results (custom DB table or post meta).
- Analyze, aggregate, or forward metrics to a central analytics solution.
Client-side: Measuring Web Vitals
Use the official web-vitals library for simple, accurate, cross-browser measurement. CDN IIFE builds make it easy to add to the front-end. If you need dependency-free implementations, you can use PerformanceObserver manually (example below).
Using the web-vitals library (recommended)
Load the library and capture metrics. The library exposes functions getCLS, getLCP, getFID (or getINP), getFCP, and getTTFB. Each metric callback receives an object with value, delta, name, id, and navigationType fields.
// Example front-end code: measure and send metrics (function () { // web-vitals IIFE from CDN: // ltscript src=https://unpkg.com/web-vitals/dist/web-vitals.iife.jsgtlt/scriptgt // Assuming web-vitals is available as window.webVitals // Replace these with values localized from PHP (see server-side below) var REST_ENDPOINT = window.wpWebVitals window.wpWebVitals.restUrl /wp-json/webvitals/v1/metrics var REST_NONCE = window.wpWebVitals window.wpWebVitals.nonce null var PAGE_ID = window.wpWebVitals window.wpWebVitals.pageId null var SEND_TIMEOUT = 5000 // ms for fetch timeout fallback // Basic queue stored in localStorage to survive page reloads or offline var QUEUE_KEY = wv_queue_v1 function enqueueMetric(payload) { try { var q = JSON.parse(localStorage.getItem(QUEUE_KEY) []) q.push(payload) localStorage.setItem(QUEUE_KEY, JSON.stringify(q)) } catch (e) { // If localStorage not available, ignore (best-effort) } } function flushQueue() { try { var q = JSON.parse(localStorage.getItem(QUEUE_KEY) []) if (!q.length) return var batched = q.slice() localStorage.removeItem(QUEUE_KEY) // send batch sendPayload({ batch: batched, pageId: PAGE_ID, queued: true }) } catch (e) { // ignore errors } } function sendPayload(data) { var body = JSON.stringify(data) // Prefer navigator.sendBeacon for reliability when unloading if (navigator.sendBeacon) { try { var headers = { type: application/json } // sendBeacon only supports limited content types in some browsers using simple fallback approach: var blob = new Blob([body], { type: application/json }) var ok = navigator.sendBeacon(REST_ENDPOINT, blob) if (ok) return Promise.resolve({ ok: true, transport: beacon }) } catch (e) { // fall through to fetch fallback } } // Fetch fallback with timeout var controller = new AbortController() var timer = setTimeout(function () { controller.abort() }, SEND_TIMEOUT) return fetch(REST_ENDPOINT, { method: POST, credentials: same-origin, headers: { Content-Type: application/json, X-WP-Nonce: REST_NONCE }, body: body, signal: controller.signal }).then(function (res) { clearTimeout(timer) if (!res.ok) { // If server rejects, optionally queue (careful to avoid infinite loops) return res.json().then(function (j) { // server may provide error we still drop metrics to avoid infinite growth return Promise.reject(j) }).catch(function () { return Promise.reject({ ok: false }) }) } return { ok: true, transport: fetch } }).catch(function () { // network error or abort — queue the original metrics for later if (data.batch) { // re-queue entire batch try { var existing = JSON.parse(localStorage.getItem(QUEUE_KEY) []) var merged = existing.concat(data.batch) localStorage.setItem(QUEUE_KEY, JSON.stringify(merged)) } catch (e) { / ignore / } } else { enqueueMetric(data) } }) } // Handler called for each metric from web-vitals function handleMetric(metric) { var payload = { name: metric.name, value: metric.value, delta: metric.delta, id: metric.id, navigationType: metric.navigationType (performance performance.getEntriesByType ? undefined : undefined), pageUrl: location.href, pageId: PAGE_ID, timestamp: Date.now() } // Try to send immediately. If offline or fetch fails, the sendPayload implementation will queue it. var result = sendPayload(payload) // If sendPayload returns a promise, attach error handling if (result result.then) { result.catch(function () { enqueueMetric(payload) }) } } // Register metrics if (window.webVitals) { webVitals.getCLS(handleMetric) webVitals.getLCP(handleMetric) // Use INP if available on the library fallback to FID if older if (webVitals.getINP) { webVitals.getINP(handleMetric) } else { webVitals.getFID(handleMetric) } webVitals.getFCP(handleMetric) webVitals.getTTFB(handleMetric) } else { // Optionally, dynamically load the library and then register metrics console.warn(web-vitals library not found.) } // Flush any queued metrics on load window.addEventListener(load, function () { setTimeout(flushQueue, 2000) }) // Try to flush when becoming online window.addEventListener(online, flushQueue) })()
Native PerformanceObserver examples
If you cant use web-vitals, you can register PerformanceObserver for LCP, CLS and other entries. This is more verbose and you must compute deltas yourself for some metrics.
// Minimal native example: observe LCP and CLS (function () { var LCP if (PerformanceObserver in window) { try { var poLCP = new PerformanceObserver(function (entryList) { var entries = entryList.getEntries() var last = entries[entries.length - 1] LCP = last.renderTime last.loadTime last.startTime // Send LCP value as milliseconds sendNative(LCP, LCP) }) poLCP.observe({ type: largest-contentful-paint, buffered: true }) } catch (e) { / not supported / } try { var clsValue = 0 var poCLS = new PerformanceObserver(function (entryList) { entryList.getEntries().forEach(function (entry) { if (!entry.hadRecentInput) { clsValue = entry.value } }) // You might want to send periodically or on pagehide sendNative(CLS, clsValue) }) poCLS.observe({ type: layout-shift, buffered: true }) } catch (e) { / not supported / } } function sendNative(name, value) { var payload = { name: name, value: value, pageUrl: location.href, timestamp: Date.now() } // Use the same sendPayload described earlier (not shown here for brevity) // sendPayload(payload) } })()
Client-side: reliability offline buffering best practices
- Prefer navigator.sendBeacon for page unload reliability. Use it first when available.
- Fall back to fetch with a reasonable timeout for browsers that dont support beacon.
- Queue metrics in localStorage or IndexedDB when offline or when transport fails flush when online or on next load.
- Batch small metrics together (per-navigation) to reduce REST calls and server load.
- Avoid sending extremely frequent metrics: send meaningful aggregates or deltas rather than streaming every minor change.
WordPress integration: enqueue script and localize REST details
Enqueue your front-end script and the web-vitals CDN in a theme or plugin. Localize the REST URL and a nonce so the script knows where to send metrics and can provide a nonce header for CSRF protection.
// Example functions.php or plugin file: enqueue and localize add_action(wp_enqueue_scripts, wv_enqueue_scripts) function wv_enqueue_scripts() { // Register web-vitals from CDN (optional: host locally) wp_register_script(web-vitals-cdn, https://unpkg.com/web-vitals/dist/web-vitals.iife.js, array(), null, true) // Your measurement script (create wv-measure.js containing the JS code above) wp_register_script(wv-measure, plugins_url(wv-measure.js, __FILE__), array(web-vitals-cdn), 1.0, true) wp_enqueue_script(web-vitals-cdn) wp_enqueue_script(wv-measure) // Localize: pass rest URL, nonce, and optional page/post ID data = array( restUrl => esc_url_raw(rest_url(webvitals/v1/metrics)), nonce => wp_create_nonce(wp_rest), // used in X-WP-Nonce header pageId => get_queried_object_id() ) wp_add_inline_script(wv-measure, window.wpWebVitals = . wp_json_encode(data) . , before) }
Server-side: Registering the REST route
Create a REST route that accepts POST requests with metric payloads. The endpoint should:
- Validate request method and JSON payload.
- Perform security checks (nonce, capability if required, rate-limiting).
- Sanitize and validate each field.
- Persist metrics in a safe storage (custom DB table, post meta, or push to external analytics).
Register route and a basic handler
// Register REST route on plugin init add_action(rest_api_init, function () { register_rest_route(webvitals/v1, /metrics, array( methods => WP_REST_Server::CREATABLE, callback => wv_receive_metrics, // Allow public access but validate nonce inside callback adjust as needed permission_callback => __return_true, )) }) / REST callback: accepts payload and stores metrics. Expected body examples: { name: LCP, value: 1234.5, id: ..., pageUrl: ..., pageId: 42, timestamp: 1610000000000 } OR { batch: [ {...}, {...} ] } / function wv_receive_metrics(WP_REST_Request request) { // CSRF / nonce check headers = request->get_headers() nonce = isset(headers[x-wp-nonce]) ? sanitize_text_field(headers[x-wp-nonce][0]) : if (!wp_verify_nonce(nonce, wp_rest)) { // Optionally allow anonymous but with other checks here we reject invalid nonces return new WP_REST_Response(array( success => false, message => Invalid nonce ), 403) } body = request->get_json_params() if (empty(body)) { return new WP_REST_Response(array(success => false, message => Empty body), 400) } global wpdb table = wpdb->prefix . webvitals // must be created on plugin activation // Accept either a single metric or a batch items = isset(body[batch]) is_array(body[batch]) ? body[batch] : array(body) inserted = 0 foreach (items as item) { // Basic validation and sanitization name = isset(item[name]) ? sanitize_text_field(item[name]) : value = isset(item[value]) ? floatval(item[value]) : null page_url = isset(item[pageUrl]) ? esc_url_raw(item[pageUrl]) : page_id = isset(item[pageId]) ? intval(item[pageId]) : 0 client_ts = isset(item[timestamp]) ? intval(item[timestamp]) : current_time(timestamp) 1000 if (!name value === null) { continue // skip invalid items } data = array( metric_name => name, metric_value => value, page_id => page_id, page_url => page_url, client_ts => gmdate(Y-m-d H:i:s, intval(client_ts / 1000)), received_at => current_time(mysql), user_agent => substr(_SERVER[HTTP_USER_AGENT] ?? , 0, 255), ) format = array(%s, %f, %d, %s, %s, %s, %s) ok = wpdb->insert(table, data, format) if (ok) inserted } return new WP_REST_Response(array(success => true, inserted => inserted), 200) }
Creating the storage table on plugin activation
register_activation_hook(__FILE__, wv_create_table) function wv_create_table() { global wpdb table = wpdb->prefix . webvitals charset_collate = wpdb->get_charset_collate() if (wpdb->get_var(SHOW TABLES LIKE table) == table) { return } sql = CREATE TABLE table ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, metric_name VARCHAR(64) NOT NULL, metric_value DOUBLE NOT NULL, page_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, page_url TEXT, client_ts DATETIME, received_at DATETIME NOT NULL, user_agent VARCHAR(255), PRIMARY KEY (id), KEY metric_name (metric_name), KEY page_id (page_id) ) charset_collate require_once(ABSPATH . wp-admin/includes/upgrade.php) dbDelta(sql) }
Alternative: store metrics as post meta
If you prefer to aggregate per post and keep the DB simpler, consider updating an aggregate value in post meta (e.g., count, average, p75). Storing every single page view in post meta is not recommended — use an aggregate or a separate table.
// Example: simple averaging in postmeta (naive) function wv_store_as_postmeta(post_id, name, value) { if (!post_id) return meta_key = wv_ . strtolower(name) . _data existing = get_post_meta(post_id, meta_key, true) if (!existing) { existing = array(count => 0, sum => 0) } existing[count] = 1 existing[sum] = floatval(value) update_post_meta(post_id, meta_key, existing) }
Security, privacy and policy considerations
- CSRF/Authentication: Use X-WP-Nonce (wp_create_nonce(wp_rest)) for protection when possible. If you want to accept metrics from anonymous users without a nonce, implement rate-limiting (IP-based or token-based) and verify referer to prevent abuse.
- Rate limiting / throttling: Add server-side rate limits to avoid spam or DDoS from clients sending metrics repeatedly. Consider per-IP or per-page limits, and reject excessive writes.
- Privacy / GDPR: Avoid storing raw IP addresses or PII. If you must log IPs, consider hashing and keep retention short. Provide opt-out or honor consent via a cookie-consent mechanism before enabling metrics collection.
- Data retention and aggregation: Do not retain raw per-user metrics forever. Aggregate into daily or hourly summaries for long-term analysis, and purge raw rows periodically.
- Payload validation: Validate metric names against an allowlist, sanitize strings, and cast numbers safely.
Testing and debugging
Use curl or a REST client to emulate client payloads and test your endpoint. Here is an example curl request to simulate a single metric (include a valid nonce header when required).
curl -X POST https://example.com/wp-json/webvitals/v1/metrics -H Content-Type: application/json -H X-WP-Nonce: your_nonce_here -d {name:LCP,value:1200.5,id:v1-123456,pageUrl:https://example.com/sample/,pageId:42,timestamp:1620000000000}
For batch tests, pass a batch array:
curl -X POST https://example.com/wp-json/webvitals/v1/metrics -H Content-Type: application/json -H X-WP-Nonce: your_nonce_here -d {batch:[{name:LCP,value:1200},{name:CLS,value:0.03}], pageId:42}
Scaling and architecture tips
- For sites with high traffic, avoid writing every metric to MySQL. Instead, buffer and batch writes, or forward metrics to a message queue (Redis, RabbitMQ, Kafka) or directly to an analytics service (BigQuery, InfluxDB).
- Compute and store aggregated metrics (percentiles, averages) rather than raw event lists for long-term storage.
- Consider asynchronous workers or WP cron tasks to process raw events into aggregates to reduce synchronous write overhead.
- Store minimal data per event: metric name, value, page identifier, timestamp. Avoid user-agent strings unless necessary.
Complete minimal plugin skeleton (concise)
The following blocks outline the minimum plugin structure: activation table creation, enqueueing scripts, registering REST route, and handling requests. Use this as a starting point for production hardening, logging, and privacy controls.
prefix . webvitals charset_collate = wpdb->get_charset_collate() if (wpdb->get_var(SHOW TABLES LIKE table) == table) return sql = CREATE TABLE table ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, metric_name VARCHAR(64) NOT NULL, metric_value DOUBLE NOT NULL, page_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0, page_url TEXT, client_ts DATETIME, received_at DATETIME NOT NULL, user_agent VARCHAR(255), PRIMARY KEY (id) ) charset_collate require_once(ABSPATH . wp-admin/includes/upgrade.php) dbDelta(sql) } add_action(wp_enqueue_scripts, wv_enqueue_scripts) function wv_enqueue_scripts() { wp_register_script(web-vitals-cdn, https://unpkg.com/web-vitals/dist/web-vitals.iife.js, array(), null, true) wp_register_script(wv-measure, plugins_url(wv-measure.js, __FILE__), array(web-vitals-cdn), 1.0, true) wp_enqueue_script(web-vitals-cdn) wp_enqueue_script(wv-measure) data = array( restUrl => esc_url_raw(rest_url(webvitals/v1/metrics)), nonce => wp_create_nonce(wp_rest), pageId => get_queried_object_id() ) wp_add_inline_script(wv-measure, window.wpWebVitals = . wp_json_encode(data) . , before) } add_action(rest_api_init, function () { register_rest_route(webvitals/v1, /metrics, array( methods => WP_REST_Server::CREATABLE, callback => wv_receive_metrics, permission_callback => __return_true, )) }) function wv_receive_metrics(WP_REST_Request request) { headers = request->get_headers() nonce = isset(headers[x-wp-nonce]) ? sanitize_text_field(headers[x-wp-nonce][0]) : if (!wp_verify_nonce(nonce, wp_rest)) { return new WP_REST_Response(array(success => false, message => Invalid nonce), 403) } body = request->get_json_params() if (empty(body)) return new WP_REST_Response(array(success => false, message => Empty body), 400) global wpdb table = wpdb->prefix . webvitals items = isset(body[batch]) is_array(body[batch]) ? body[batch] : array(body) inserted = 0 foreach (items as item) { name = isset(item[name]) ? sanitize_text_field(item[name]) : value = isset(item[value]) ? floatval(item[value]) : null page_url = isset(item[pageUrl]) ? esc_url_raw(item[pageUrl]) : page_id = isset(item[pageId]) ? intval(item[pageId]) : 0 client_ts = isset(item[timestamp]) ? intval(item[timestamp]) : current_time(timestamp) 1000 if (!name value === null) continue data = array( metric_name => name, metric_value => value, page_id => page_id, page_url => page_url, client_ts => gmdate(Y-m-d H:i:s, intval(client_ts / 1000)), received_at => current_time(mysql), user_agent => substr(_SERVER[HTTP_USER_AGENT] ?? , 0, 255), ) format = array(%s, %f, %d, %s, %s, %s, %s) ok = wpdb->insert(table, data, format) if (ok) inserted } return new WP_REST_Response(array(success => true, inserted => inserted), 200) } ?>
Final implementation tips
- Host the web-vitals library locally if you must avoid external CDNs.
- Keep the payload minimal and avoid sending PII.
- Monitor disk growth if you store raw events implement scheduled cleanup.
- Use aggregation jobs to compute P75, median, and trending metrics and expose them via a dashboard or exported CSV.
- Test across browsers (Safari, iOS, Chrome Android) because available APIs and timing semantics can vary.
Useful links
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |