How to measure Web Vitals in JS and send them to the REST API in WordPress

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

  1. Measure metrics in the browser using the web-vitals library (recommended) or the native Performance API.
  2. Send each metric to a WordPress REST API endpoint using navigator.sendBeacon where possible, falling back to fetch. Buffer metrics offline and retry.
  3. Secure the endpoint (nonce, capability checks, rate-limiting), sanitize inputs, and store results (custom DB table or post meta).
  4. 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 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *