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:
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.
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.
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.
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:
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.
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:
# 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.
# 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
sendBeaconto 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.






Comments
Loading comments…