Skip to content
ExponaBLOG
PROMETHEUS, GRAFANA & MONITORING · DAY 065 / 100

Monitoring frontend performance: lessons from production

Real user metrics.

Expona Admin7 min read

Monitoring frontend performance: lessons from production

Most monitoring stories focus on backend infrastructure, databases, and APIs—but your users experience your product through a browser. By the time a request reaches your backend, half the user experience has already been shaped by network conditions, JavaScript parsing, and rendering delays. We've learned in production that monitoring frontend performance: lessons from production means collecting real user metrics directly from browsers, aggregating them server-side, and correlating them with backend traces to find the true bottlenecks.

Why frontend monitoring matters

A 200ms backend response time means nothing if your JavaScript bundle takes 3 seconds to parse or your Cumulative Layout Shift (CLS) frustrates users halfway through checkout. Real user monitoring (RUM) captures what synthetic tests miss: the variability of real networks, device capabilities, and user behavior. We've found that ~40% of performance issues reported by customers never appear in lab tests because they only manifest under specific combinations of bandwidth, CPU, and third-party script loading.

Frontend monitoring also bridges the gap between frontend and backend teams. When a page load degrades, knowing whether it's a 500ms JavaScript execution spike or a 500ms API call transforms the incident response. Without this visibility, teams guess.

Collect Core Web Vitals from browsers

Core Web Vitals are standardized metrics that Google and browsers expose via the Web Vitals API. The three main signals are:

  • Largest Contentful Paint (LCP): Time until the largest visible element renders (target: <2.5s).
  • First Input Delay (FID) or Interaction to Next Paint (INP): Delay from user interaction to next paint (target: <100ms).
  • Cumulative Layout Shift (CLS): Sum of unexpected layout shifts (target: <0.1).

We collect these using the web-vitals library, a lightweight npm package maintained by Google. Here's a practical setup:

javascript

import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

const vitals = {};

getCLS(metric => {
  vitals.cls = metric.value;
  vitals.cls_rating = metric.rating; // 'good', 'needs-improvement', 'poor'
});

getLCP(metric => {
  vitals.lcp = metric.value;
  vitals.lcp_rating = metric.rating;
});

getINP(metric => {
  vitals.inp = metric.value;
  vitals.inp_rating = metric.rating;
});

getTTFB(metric => {
  vitals.ttfb = metric.value;
});

getFCP(metric => {
  vitals.fcp = metric.value;
});

// Send when navigation is complete
window.addEventListener('beforeunload', () => {
  sendMetrics(vitals);
});

Beyond Core Web Vitals, collect context: page path, device type, connection speed (from the Network Information API), and user segment. This context is what transforms raw numbers into actionable insights.

javascript

const enrichMetrics = (vitals) => {
  const connection = navigator.connection || navigator.mozConnection;
  return {
    ...vitals,
    page_path: window.location.pathname,
    device_type: /mobile|tablet/i.test(navigator.userAgent) ? 'mobile' : 'desktop',
    effective_type: connection?.effectiveType,
    user_id: getUserId(), // your session tracking
    timestamp: new Date().toISOString(),
  };
};

Deploy this collection script early in your HTML <head> to capture paint metrics accurately. We recommend using a performance observer for more granular control over when metrics are sent.

Send to an endpoint and aggregate

Raw metrics in isolation are noise. You need a backend that accepts, validates, and stores performance data so you can aggregate and query it. We've built this endpoint in Node.js/NestJS, but the pattern is language-agnostic.

yaml

POST /api/metrics/frontend
Content-Type: application/json

{
  "page_path": "/checkout/cart",
  "device_type": "mobile",
  "effective_type": "4g",
  "lcp": 2100,
  "lcp_rating": "good",
  "inp": 85,
  "inp_rating": "good",
  "cls": 0.08,
  "cls_rating": "good",
  "ttfb": 250,
  "fcp": 800,
  "user_id": "user_12345",
  "timestamp": "2024-01-15T10:30:45Z"
}

The endpoint should be lightweight: minimal processing, asynchronous writes. We write directly to a time-series database (InfluxDB, TimescaleDB) or log to a Kafka topic for later batch ingestion. Never make this endpoint slow—you'll create jank in the user's browser if they're waiting for the HTTP response to complete.

javascript

export const sendMetrics = async (vitals) => {
  try {
    // Fire and forget; don't await
    navigator.sendBeacon('/api/metrics/frontend', JSON.stringify(enrichMetrics(vitals)));
  } catch (e) {
    // Silently fail to avoid impacting user experience
    console.error('Failed to send metrics:', e);
  }
};

Using sendBeacon is critical: it queues the request to survive page navigation, even if the user closes the tab. This ensures you don't lose data during page transitions.

On the backend, store this data with a schema that includes dimensions (page, device, effective_type) and measurements (lcp, inp, cls). If you're using Prometheus, transform this data into histograms or summaries:

yaml

frontend_lcp_ms_bucket{page="/checkout/cart",device="mobile",le="1000"} 120
frontend_lcp_ms_bucket{page="/checkout/cart",device="mobile",le="2500"} 445
frontend_lcp_ms_bucket{page="/checkout/cart",device="mobile",le="+Inf"} 500

frontend_cls_bucket{page="/checkout/cart",device="desktop",le="0.05"} 890
frontend_cls_bucket{page="/checkout/cart",device="desktop",le="0.1"} 950

Faro for Grafana-based frontend monitoring

If you're already using Grafana, Faro (Grafana's Real User Monitoring agent) integrates natively and handles most of the collection complexity for you. Faro captures Core Web Vitals, custom events, errors, and console logs, then ships them to Grafana Cloud or a self-hosted Loki instance.

javascript

import { initializeFaro } from '@grafana/faro-web-sdk';

initializeFaro({
  url: 'https://your-grafana-instance.com/collect',
  app: {
    name: 'checkout-frontend',
    version: '1.2.3',
  },
  sessionTracking: {
    enabled: true,
  },
  beforeSend: (payload) => {
    // Filter sensitive data before sending
    if (payload.logs) {
      payload.logs = payload.logs.filter(log => !log.message.includes('password'));
    }
    return payload;
  },
});

// Track custom events
window.faro?.api?.pushEvent('checkout_initiated', {
  cart_value: 1250,
  items_count: 5,
});

Faro's integration with Grafana means you can correlate frontend metrics with backend traces via trace IDs. When you see LCP spike, you can immediately check if a specific API endpoint slowed down. This is where the real value emerges for production debugging.

Faro also handles session replay (with privacy controls), error tracking with stack traces, and breadcrumb trails. In a SaaS or e-commerce environment, this context is often the difference between a 10-minute fix and a 2-hour investigation.

Split by page and device

Raw aggregates hide problems. An average LCP of 1.8s across your entire site might mask a 3.5s LCP on your mobile checkout, which is bleeding conversions. Always slice metrics by page path and device type at collection time.

In your Grafana dashboards, create panels that break down each vital by these dimensions:

yaml

# Example Prometheus query
histogram_quantile(0.75, 
  sum by (page_path, device_type) 
  (rate(frontend_lcp_ms_bucket[5m]))
)

This query shows the p75 LCP for each page-device combination over the last 5 minutes. You can immediately see if mobile product pages are lagging behind desktop or if a specific checkout step degraded.

Set up alerts on per-page metrics, not global ones. A page-specific alert for LCP > 3s over 5 minutes is far more actionable than a site-wide alert, because you know exactly which page to investigate and which team owns it.

yaml

# Alert rule example
alert: HighLCPMobilCheckout
expr: histogram_quantile(0.75, rate(frontend_lcp_ms_bucket{page_path="/checkout",device_type="mobile"}[5m])) > 3000
for: 5m
annotations:
  summary: "Mobile checkout LCP exceeds 3s (p75)"

We also recommend creating a "frontend health scorecard" dashboard that shows pass/fail rates for each vital by page. A simple heat map showing green (good), yellow (needs improvement), and red (poor) lets stakeholders quickly spot problem areas without needing to understand histogram quantiles.

Common mistakes

  • Ignoring network conditions: A metric is meaningless without context. Always collect effective connection type and device type. A 2.5s LCP on 4G mobile is different from 2.5s on fiber desktop.
  • Sampling everything equally: Your checkout page is more important than your landing page for business outcomes, but you're treating them the same. Consider importance-weighted sampling: capture 100% of checkout metrics, 10% of blog traffic.
  • Sending metrics synchronously: If your metrics endpoint is slow, you've introduced jank into the very user experience you're measuring. Always use async patterns or sendBeacon.
  • Not filtering personally identifiable information (PII): Console logs and error messages often leak passwords, tokens, or customer IDs. Sanitize before sending to a third party.
  • Missing the "opportunity cost" of fixes: Optimizing a metric on a page 5% of users visit may not be worth engineering time. Combine metrics with traffic volume.

Key takeaways

  • Collect Core Web Vitals directly from browsers using the web-vitals library and send via sendBeacon to survive page navigation.
  • Always enrich metrics with context: page path, device type, connection speed, and user segment. Raw numbers are noise without dimensions.
  • Store metrics in a time-series database and expose them as Prometheus histograms so you can query quantiles per page and device.
  • If using Grafana, Faro integrates native real user monitoring and allows correlation of frontend metrics with backend traces via trace IDs.
  • Split all alerts and dashboards by page and device; site-wide averages hide critical problems. Set thresholds per page based on business impact.
  • Avoid synchronous or expensive metric collection; frontend monitoring should never degrade the experience it's measuring.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles