Overview
No matter how fancy your backend monitoring is, if the user opens the page and sees a blank screen for 5 seconds, your all-green SLOs mean nothing.
This isn’t me being contrarian. Many teams have dashboards full of CPU, memory, QPS, and P99 latency metrics, looking perfectly healthy. But real users are already flooding customer support with complaints — “page loads slow,” “can’t click anything,” “images keep jumping around.” Where’s the problem? You’re monitoring from the server’s perspective, but users see the browser’s perspective. Between them sits CDN caching, DNS resolution, third-party script blocking, and the rendering pipeline. Any weak link breaks the user’s experience.
This article solves one problem: how to quantify “the performance users actually feel” into metrics, collect them, set up alerts, and ultimately form an optimization closed loop. The core is two things — the Core Web Vitals metric system and Real User Monitoring (RUM).
Frontend performance monitoring differs fundamentally from backend monitoring: backend monitors “how fast my service processes,” frontend monitors “how long the user waits.” The same API might have a backend P99 of 50ms, but the user on 4G waits 3 seconds to see content — that 2.95-second gap is only visible through frontend monitoring.
Core Web Vitals: The “Thermometer” for User Experience
Three Core Metrics
Google’s Core Web Vitals is currently the industry’s most mainstream frontend experience quantification standard, focusing on three dimensions:
| Metric | Full Name | What It Measures | Good | Needs Improvement | Poor |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | Largest contentful paint time | ≤ 2.5s | 2.5-4.0s | > 4.0s |
| INP | Interaction to Next Paint | Interaction to next paint latency | ≤ 200ms | 200-500ms | > 500ms |
| CLS | Cumulative Layout Shift | Cumulative layout shift score | ≤ 0.1 | 0.1-0.25 | > 0.25 |
Let me explain what these three metrics actually measure:
- LCP: After the user opens the page, how long until the main content (usually a large image or headline) renders. This is the source of the “fast or slow” feeling.
- INP: After the user clicks a button or scrolls, how long until the page responds. This is the source of the “laggy or smooth” feeling. In March 2024, it officially replaced the old FID (First Input Delay) metric, because FID only measured the first interaction, while INP measures all interactions — closer to real experience.
- CLS: How severely elements jump around during page loading. You’re about to click a button, but an ad slot inserts itself and pushes the button away — you click the ad instead. CLS quantifies this kind of experience.
Beyond these three core metrics, several auxiliary metrics are worth tracking:
| Metric | Full Name | Meaning | Good |
|---|---|---|---|
| TTFB | Time to First Byte | First byte time, server response speed | ≤ 800ms |
| FCP | First Contentful Paint | First contentful paint | ≤ 1.8s |
| TTI | Time to Interactive | Time to interactive | — |
Why INP Replaced FID
This deserves a separate explanation, because many people are still reporting FID data.
FID only measures the latency of the user’s first interaction with the page. The problem: the first interaction might be fast, but subsequent interactions might be laggy. A classic example: the page responds quickly in the first 2 seconds, but at 5 seconds a JavaScript long task runs, and when the user clicks a button at that moment, it freezes — FID can’t capture this.
INP takes the worst value across all interactions (after removing outliers), reflecting “the worst interaction experience throughout the page’s entire lifecycle.” For users, the worst single interaction is the most memorable one.
Google’s official announcement explicitly stated that INP became a stable Core Web Vital in March 2024, replacing FID. If you’re still looking at FID data, it’s time to switch.
Two Monitoring Approaches: RUM vs Synthetic
Frontend performance monitoring has two data collection methods, each with its own applicable scenarios:
| Dimension | RUM (Real User Monitoring) | Synthetic Monitoring |
|---|---|---|
| Data source | Real user browsers | Simulated browsers (Lighthouse, Headless Chrome) |
| Network environment | Real (4G/WiFi/weak network mix) | Controllable (preset throttling) |
| Coverage | All users | Fixed URLs + fixed scripts |
| Strength | Reflects real experience | Reproducible, comparable, regression-ready |
| Weakness | Noisy, hard to reproduce | Deviates from real users |
| Typical tools | Baidu Analytics, web-vitals.js, Sentry | Lighthouse CI, WebPageTest |
My recommendation: use both, but with different responsibilities:
- RUM is the primary data source, telling you “how users actually experience it.” Alert thresholds and SLO settings are based on RUM data.
- Synthetic monitoring is a supplementary tool, used for performance regression detection in the CI/CD pipeline. Run Lighthouse before each release — if P75 LCP is 500ms worse than last time, block it. In this scenario, synthetic monitoring is more appropriate than RUM because it’s reproducible.
Don’t make a common mistake: using synthetic monitoring data as if it were real user experience. The LCP from a lab running on gigabit fiber might be 0.8 seconds, but the real user on 4G in a subway might wait 4 seconds. Two completely different things.
web-vitals.js: The Lightest RUM Collection Solution
Google’s officially maintained web-vitals library is currently the cleanest Core Web Vitals collection tool. It solves a pain point of browser native APIs: these metrics aren’t one-time events — they require continuous observation.
Why You Can’t Just Use PerformanceObserver Directly
Browsers provide the PerformanceObserver API to listen for performance events. But Core Web Vitals has a characteristic — LCP and CLS are “final value” concepts, only determined when the page unloads or goes to background. If you start listening and reporting during page load, you might get intermediate values, not final ones.
The web-vitals library handles these edge cases for you:
// Minimal collection solution - using web-vitals library (v4+)
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';
// Reporting function - sends to your backend collection service
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name, // Metric name: LCP/INP/CLS etc.
value: metric.value, // Numeric value
rating: metric.rating, // good/needs-improvement/poor
id: metric.id, // Unique identifier for dedup
delta: metric.delta, // Delta value (CLS-specific)
page_url: window.location.href,
page_path: window.location.pathname,
user_agent: navigator.userAgent,
// Sampling identifier to avoid overwhelming backend with full reporting
session_id: getSessionId(),
timestamp: Date.now(),
});
// Use sendBeacon instead of fetch, reason explained below
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/rum/collect', body);
} else {
fetch('/api/rum/collect', { body, method: 'POST', keepalive: true });
}
}
// Bind listeners - these callbacks fire when metrics are finalized
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onTTFB(sendToAnalytics);
onFCP(sendToAnalytics);
The Reporting Timing Trap: sendBeacon vs fetch
This is the most common pitfall in frontend performance monitoring.
The final values of CLS and INP are only determined at page unload (pagehide or visibilitychange events). If you use fetch to report at page unload, it will likely fail — the browser terminates in-flight requests when the page closes.
sendBeacon is an API designed specifically for this scenario: it puts data into a browser queue, and even if the page closes, it finishes sending in the background. But it has a size limit (typically 64KB), so don’t stuff large objects in there.
If sendBeacon is unavailable (old browsers), use fetch with keepalive: true as a fallback, with similar effect.
Sampling Strategy: Don’t Report Everything
If your site has 1 million daily PVs, full reporting of 5 metrics per page is 5 million records/day. Your backend can’t handle it, and you don’t need it.
// Sample by session - 10% of sessions collect all metrics
function shouldSample() {
const sampleRate = 0.1; // 10%
// Sample by session dimension so the same user is either fully sampled or not at all
// Avoids partial metric collection for a single user, which fragments data
const sessionId = getSessionId();
const hash = simpleHash(sessionId);
return (hash % 100) < (sampleRate * 100);
}
function simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
if (shouldSample()) {
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
}
What sampling rate is appropriate? It depends on your traffic volume:
| Daily PV | Recommended Sampling Rate | Daily Collection (Estimate) |
|---|---|---|
| < 10K | 100% | 50K records |
| 10K - 100K | 50% | 2.5M - 25M records |
| 100K - 1M | 10% | 5M - 50M records |
| > 1M | 1-5% | 5M - 50M records |
The principle: keep daily data under a few million records, and ensure P75 values are statistically stable.
Backend Collection and Storage
The frontend sends data out, but how the backend receives and stores it determines how deep your analysis can go.
Collection Service Design
A minimal viable RUM collection service in Go looks like this:
package main
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
type RumMetric struct {
Name string `json:"name"`
Value float64 `json:"value"`
Rating string `json:"rating"`
ID string `json:"id"`
Delta float64 `json:"delta"`
PageURL string `json:"page_url"`
PagePath string `json:"page_path"`
UserAgent string `json:"user_agent"`
SessionID string `json:"session_id"`
Timestamp int64 `json:"timestamp"`
}
func collectHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Method Not Allowed", 405)
return
}
var m RumMetric
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
http.Error(w, "Bad Request", 400)
return
}
// Basic validation - filter out garbage data
if m.Name == "" || m.Value <= 0 || m.PagePath == "" {
http.Error(w, "Invalid metric", 400)
return
}
// Write to Redis Stream, backend consumes asynchronously to write to TSDB
// Using Stream instead of direct DB writes prevents traffic spikes from crushing the database
data, _ := json.Marshal(m)
err := rdb.XAdd(ctx, &redis.XAddArgs{
Stream: "rum:metrics",
Values: map[string]interface{}{
"data": string(data),
"received": time.Now().UnixMilli(),
},
MaxLen: 100000, // Prevent backlog from blowing up memory
}).Err()
if err != nil {
log.Printf("redis XAdd failed: %v", err)
http.Error(w, "Internal Error", 500)
return
}
w.WriteHeader(204)
}
func main() {
http.HandleFunc("/api/rum/collect", collectHandler)
log.Println("RUM collector listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Storage Solution Selection
RUM data is typical time-series data. Storage options fall into several directions:
| Solution | Suitable Scale | Query Capability | Cost | Recommended Scenario |
|---|---|---|---|---|
| Prometheus | Small-medium | PromQL aggregation | Low | Teams already using Prometheus |
| VictoriaMetrics | Medium-large | PromQL + high compression | Medium | Need long-term storage + cost savings |
| ClickHouse | Large | Flexible SQL queries | Medium | Need multi-dimensional analysis |
| Elasticsearch | Large | Full-text search + aggregation | High | Already have ELK stack |
| TimescaleDB | Medium | SQL | Medium | Need SQL + relational data |
My practical recommendation:
- Daily PV < 100K: Push directly to Prometheus, use histograms to record LCP/INP/CLS distributions. Simple and effective, done in a few days.
- Daily PV 100K - 1M: VictoriaMetrics, PromQL-compatible but storage cost is 1/5 to 1/10 of Prometheus.
- Daily PV > 1M: ClickHouse, because you need multi-dimensional analysis by page, device, region, and network type — PromQL can’t express queries that flexible.
Prometheus Storage Example
If using Prometheus, you need to convert individual metrics into histograms, using pushgateway or writing your own exporter:
// Convert RUM data to Prometheus histograms
var (
lcpHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "rum_lcp_seconds",
Help: "Largest Contentful Paint in seconds",
Buckets: []float64{0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 8.0},
})
inpHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "rum_inp_seconds",
Help: "Interaction to Next Paint in seconds",
Buckets: []float64{0.05, 0.1, 0.15, 0.2, 0.3, 0.5, 0.8, 1.0},
})
clsHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "rum_cls_score",
Help: "Cumulative Layout Shift score",
Buckets: []float64{0.01, 0.05, 0.1, 0.15, 0.2, 0.25, 0.5},
})
)
func recordMetric(m RumMetric) {
// Use labels to distinguish page paths and device types
// Note: label cardinality must not be too high, or Prometheus will OOM
switch m.Name {
case "LCP":
lcpHistogram.Observe(m.Value / 1000) // ms to s
case "INP":
inpHistogram.Observe(m.Value / 1000)
case "CLS":
clsHistogram.Observe(m.Value)
}
}
Beware the label cardinality trap: if you use
page_pathas a label and your site has tens of thousands of URLs, Prometheus will OOM from label combination explosion. The solution is to normalize page_path (e.g., keep only first-level paths like/product,/article,/search), or use a high-cardinality-capable storage like ClickHouse.
Alert Rule Design
With data in place, the next step is configuring alerts. Frontend performance alerting follows the same logic as backend alerting: define thresholds, trigger when exceeded.
Use P75, Not Average
This is the golden rule of frontend performance monitoring: use percentiles (P75), not averages.
The reason is simple: averages get skewed by tail data. Out of 100 users, 90 have LCP of 1 second, 10 have 10 seconds. The average is 1.9 seconds — looks fine. But P75 is 1 second, P95 is 10 seconds. You care about those 10 users who waited 10 seconds, and the average hides them.
Google’s official Core Web Vitals assessment uses P75: a page’s LCP P75 ≤ 2.5 seconds is considered “good.” This means 75% of users experience it within 2.5 seconds.
PromQL Alert Rules
# alertmanager_rules.yml
groups:
- name: frontend-performance
rules:
# LCP P75 exceeds 4 seconds - poor experience, alert immediately
- alert: FrontendLCPPoor
expr: |
histogram_quantile(0.75,
sum(rate(rum_lcp_seconds_bucket[1h])) by (le, page_group)
) > 4.0
for: 10m
labels:
severity: warning
team: frontend
annotations:
summary: "LCP P75 exceeds 4s: {{ $labels.page_group }}"
description: "Page {{ $labels.page_group }} LCP P75 is {{ $value }}s, entered poor experience range"
# INP P75 exceeds 500ms - interaction lag
- alert: FrontendINPPoor
expr: |
histogram_quantile(0.75,
sum(rate(rum_inp_seconds_bucket[1h])) by (le, page_group)
) > 0.5
for: 10m
labels:
severity: warning
team: frontend
annotations:
summary: "INP P75 exceeds 500ms: {{ $labels.page_group }}"
description: "Interaction latency P75 is {{ $value }}s, users will feel noticeable lag"
# CLS P75 exceeds 0.25 - severe layout shift
- alert: FrontendCLSPoor
expr: |
histogram_quantile(0.75,
sum(rate(rum_cls_score_bucket[1h])) by (le, page_group)
) > 0.25
for: 10m
labels:
severity: warning
team: frontend
annotations:
summary: "CLS P75 exceeds 0.25: {{ $labels.page_group }}"
description: "Severe layout shift, P75 is {{ $value }}"
# Data volume drop - collection might be broken
- alert: RUMDataDrop
expr: |
sum(rate(rum_lcp_seconds_bucket[5m])) <
sum(rate(rum_lcp_seconds_bucket[5m] offset 1d)) * 0.3
for: 15m
labels:
severity: critical
team: sre
annotations:
summary: "RUM data volume dropped over 70%"
description: "Collection script may have failed or reporting endpoint is down"
The last RUMDataDrop alert is easily overlooked but critical. RUM collection depends on frontend JS — once a release breaks the reporting logic, data volume plummets, but this doesn’t mean “performance improved,” it means “monitoring went blind.” Like backend monitoring, monitoring itself needs monitoring — meta-monitoring.
Alert Tiering
Not all performance degradations warrant waking someone up at 3 AM. My tiering recommendation:
| Level | Trigger Condition | Notification Method | Response Time |
|---|---|---|---|
| P0 | Collection completely interrupted | Phone + IM | 15 minutes |
| P1 | Core page LCP P75 > 4s for 10 min | IM group @mention | 1 hour |
| P2 | Secondary page INP P75 > 500ms for 30 min | IM group message | 4 hours |
| P3 | CLS P75 > 0.25 for 1 hour | Daily report summary | Next business day |
P0 must phone — collection being down means you can’t see any frontend data, which is worse than poor performance. A system with a monitoring blind spot means you don’t even know when problems occur.
Optimization Directions: From Metrics to Code
Once alerts fire, how do you fix things? Different metrics require very different optimization approaches.
LCP Optimization
The LCP element is usually the first-screen hero image or headline. The optimization approach is simple: make this element appear as fast as possible.
<!-- Wrong: lazy-loading the LCP image -->
<img src="hero.jpg" loading="lazy" alt="Hero image">
<!-- Lazy loading delays first-screen image loading, LCP explodes -->
<!-- Correct: use preload + eager for LCP elements -->
<link rel="preload" as="image" href="hero.webp" fetchpriority="high">
<img src="hero.webp" width="1080" height="720" fetchpriority="high" alt="Hero image">
<!-- Use modern formats: WebP/AVIF are 30-50% smaller than JPEG -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" width="1080" height="720" alt="Hero image">
</picture>
| Optimization | Expected Gain | Difficulty |
|---|---|---|
| LCP image preload | -0.5 to -1.5s | Low |
| WebP/AVIF replacing JPEG | -30% to -50% size | Low |
| CDN for static assets | -200ms to -800ms | Low |
| Server-side rendering (SSR) | -1s to -3s | High |
| Critical CSS inlining | -200ms to -500ms | Medium |
| Font preload + font-display: swap | -300ms to -800ms | Low |
INP Optimization
90% of INP lag is caused by JavaScript long tasks (tasks executing longer than 50ms). The optimization approach: break long tasks into short ones.
// Wrong: processing large data all at once
function processAllItems(items) {
items.forEach(item => {
// Each takes 5ms, 1000 items = 5-second long task
heavyProcess(item);
});
}
// Correct: use scheduler.yield or setTimeout to chunk
async function processAllItems(items) {
const CHUNK_SIZE = 20; // Process 20 per batch
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => heavyProcess(item));
// Yield to main thread so browser can respond to user input
if (typeof scheduler !== 'undefined' && scheduler.yield) {
// New API, Chrome 129+ support
await scheduler.yield();
} else {
// Fallback: use setTimeout(0) to yield main thread
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
scheduler.yield() is a new API standardized in 2024, designed specifically for splitting long tasks. It’s more precise than setTimeout(0) — it lets the browser prioritize pending user input before returning to your task.
CLS Optimization
CLS root causes are almost always “element dimensions are unknown.” The fix is one sentence: specify dimensions for all media elements.
<!-- Wrong: no width/height specified -->
<img src="photo.jpg" alt="Photo">
<!-- Browser doesn't know image size, reflows after load, CLS spikes -->
<!-- Correct: specify dimensions -->
<img src="photo.jpg" width="800" height="600" alt="Photo">
<!-- Better: use CSS aspect-ratio for responsive design -->
<img src="photo.jpg" style="aspect-ratio: 4/3; width: 100%;" alt="Photo">
<!-- Ad slots: reserve placeholder space -->
<div class="ad-slot" style="min-height: 250px;">
<!-- Has height before ad loads, won't push content below -->
</div>
| CLS Root Cause | Share (Measured) | Fix |
|---|---|---|
| Images without dimensions | 40% | Add width/height or aspect-ratio |
| Font loading flicker | 25% | font-display: swap + font preload |
| Dynamically inserted ads | 20% | Reserve placeholder min-height |
| Async content loading | 15% | Skeleton screen placeholder |
Grafana Dashboard
With monitoring and alerting in place, you need a visualization dashboard for the team to see trends at a glance.
Recommended core panels:
- Overview panel: P75 values of three core metrics, grouped by page, with green/yellow/red color coding
- Trend panel: LCP/INP/CLS P75 trend lines over the past 7 days, annotated with release timestamps
- Distribution panel: LCP histogram showing distribution shape rather than a single value
- Device panel: P75 comparison grouped by device type (Mobile/Tablet/Desktop)
- Page panel: Metric ranking grouped by page_group, identifying worst-performing pages
# Overview panel - three core metrics P75 grouped by page
histogram_quantile(0.75, sum(rate(rum_lcp_seconds_bucket[1h])) by (le, page_group))
# Device dimension comparison
histogram_quantile(0.75, sum(rate(rum_lcp_seconds_bucket[1h])) by (le, device_type))
# Before/after release comparison - use offset to compare same period yesterday
histogram_quantile(0.75, sum(rate(rum_lcp_seconds_bucket[1h])) by (le))
-
histogram_quantile(0.75, sum(rate(rum_lcp_seconds_bucket[1h] offset 1d)) by (le))
Release timestamp annotation is a very useful feature in practice. Mark each release time as a Grafana annotation — if LCP spikes after a release, you can immediately correlate it to which release introduced the regression.
Practical Pitfalls Summary
After implementing frontend performance monitoring across several projects, here are recurring pitfalls:
Pitfall 1: Only reporting successes, not failures. The collection script itself errors out, user-side metrics aren’t collected, and your dashboard shows everything is fine. Fix: add error reporting to the collection script, capture its own exceptions with window.onerror, and push a separate rum_collector_error metric.
Pitfall 2: SPA route changes don’t reset. After SPA route changes, the LCP candidate element changes, but web-vitals doesn’t auto-reset. You need to call onLCP’s cleanup function and rebind on route changes, or use SPA-specific collection logic.
Pitfall 3: Third-party scripts drag you down. Analytics SDKs, ad SDKs, customer service plugins — these third-party scripts severely drag down INP. You optimize for hours with no effect, then discover a customer service plugin’s JS hogs the main thread for 2 seconds. Fix: use requestIdleCallback to delay-load non-critical third-party scripts, or use Partytown to run third-party scripts in a Web Worker.
Pitfall 4: Weak-network users get missed by sampling. If sampling by session, weak-network users might leave before the collection script finishes loading, and their data never enters your system. Fix: load the collection script as early as possible in <head> (not before </body>), using defer rather than dynamic loading.
Pitfall 5: Treating lab data as real data. Lighthouse CI scores look great, but that’s simulation under ideal network conditions. Real user experience is only told by RUM. Don’t report Lighthouse scores as real user experience — you’ll get caught.
Summary
The core approach to frontend performance monitoring: use Core Web Vitals to quantify user experience, use RUM to collect real data, use P75 percentiles for alert thresholds, and ultimately form a “collect-alert-optimize-verify” closed loop.
Several key decision points:
- Choose INP over FID — FID has been deprecated by Google, INP reflects real interaction experience
- Choose RUM over synthetic monitoring — synthetic monitoring is for CI regression, alerts and SLOs must be based on RUM
- Choose P75 over average — averages mask tail-user problems
- Select storage by traffic — small traffic uses Prometheus, large traffic uses ClickHouse, don’t force it
- Alerts must include meta-monitoring — collection being down is worse than poor performance, you need to detect it
Frontend performance monitoring isn’t a one-time project — it’s continuous operation. Review trends weekly, compare data before and after each release, and fix regressions promptly. Users won’t praise you for optimizing LCP from 3 seconds to 2 seconds, but they’ll silently stay because they no longer complain about “slow pages” — that’s the value of doing this work.
References & Acknowledgments
The following resources were referenced during the writing of this article. Thanks to the original authors for their contributions:
- web-vitals - GitHub — Google Chrome team, official Core Web Vitals collection library, the core dependency of this article’s collection approach
- 前端性能优化:Web Vitals 指标分析与实战改进 — dblens, detailed Core Web Vitals metrics and code-level optimization approaches
- 极致渲染:Paint Timing API 与 Web 性能优化深度实战 — CSDN blogger, browser rendering pipeline and Paint Timing API internals
- 第6期:前端性能优化 —— 从指标监控到极致渲染 — 51CTO blogger, 2026 Core Web Vitals metric system updates and INP replacing FID explanation