FE-08 — Measuring Performance
PerformanceObserver, Navigation Timing, and Building Real User Monitoring
Part of The Production Frontend Engineer series. Prerequisites: FE-04 (Web Vitals, lab vs field data), FE-05 (caching, CDN), FE-07 (memory, GC pauses).
Opening
Every chapter in this series has given you something to fix. This chapter gives you the infrastructure to know whether your fixes worked — not in a Lighthouse test on your MacBook, but for the actual distribution of humans using your application across their actual devices, networks, and geographies. Measuring performance without Real User Monitoring is like diagnosing an illness from a textbook description instead of a blood test. You know what the symptoms of LCP problems look like in theory, but you do not know which 15% of your users are experiencing them, on which devices, in which countries, triggered by which actions. This chapter builds the measurement layer. Once it is in place, every other chapter in this series becomes not a set of things to do but a set of hypotheses to test and verify.
Part 1: The Performance API — What the Browser Measures Natively
The browser's Performance API is a timeline of timestamped events recorded automatically during page load and user interaction. It is accessible through the performance object and PerformanceObserver. Before building any monitoring system, understand what the browser is already measuring.
The Navigation Timing API
When a user navigates to your page, the browser records timestamps for every significant event in the loading process. These are accessible via performance.getEntriesByType('navigation').
const nav = performance.getEntriesByType('navigation')[0]
// All times are in milliseconds from the navigation start
nav.startTime // always 0 (the baseline)
nav.fetchStart // when the browser started the fetch (after cache checks)
nav.domainLookupStart // DNS lookup started
nav.domainLookupEnd // DNS lookup completed
nav.connectStart // TCP connection started
nav.connectEnd // TCP connection completed
nav.secureConnectionStart // TLS handshake started
nav.requestStart // browser sent the HTTP request
nav.responseStart // first byte of response arrived (TTFB)
nav.responseEnd // last byte of response arrived
nav.domInteractive // HTML parsed, DOM built (scripts may still be loading)
nav.domContentLoadedEventEnd // DOMContentLoaded fired
nav.loadEventEnd // window load event fired
nav.duration // total navigation duration
These are the actual timestamps the browser records for the physical events from FE-00 and FE-04. responseStart - requestStart is your actual TTFB. domainLookupEnd - domainLookupStart is your actual DNS resolution time.
const nav = performance.getEntriesByType('navigation')[0]
const ttfb = nav.responseStart - nav.requestStart
const dnsTime = nav.domainLookupEnd - nav.domainLookupStart
const tcpTime = nav.connectEnd - nav.connectStart
const tlsTime = nav.connectEnd - nav.secureConnectionStart
console.log(`TTFB: ${ttfb.toFixed(1)}ms`)
console.log(`DNS: ${dnsTime.toFixed(1)}ms`)
console.log(`TCP: ${tcpTime.toFixed(1)}ms`)
console.log(`TLS: ${tlsTime.toFixed(1)}ms`)
This is the programmatic version of the DevTools Network tab waterfall, running on real users' machines with their actual network conditions.
The Resource Timing API
Every resource loaded by the page gets a timing entry:
const resources = performance.getEntriesByType('resource')
resources.forEach(r => {
const dns = r.domainLookupEnd - r.domainLookupStart
const ttfb = r.responseStart - r.requestStart
const download = r.responseEnd - r.responseStart
console.log(`${r.name}: DNS=${dns}ms TTFB=${ttfb}ms Download=${download}ms`)
})
This identifies which specific resource has a high TTFB (CDN not serving from edge), which has high DNS time (domain needs preconnect hint), and which has high download time (resource is too large or connection is slow).
Cross-origin resources show zeroed timing values by default to protect server privacy. The server opts in by sending Timing-Allow-Origin: * in its response headers.
Part 2: PerformanceObserver — Listening for Events as They Happen
performance.getEntriesByType() retrieves past entries. PerformanceObserver listens for new entries as they are created — necessary for metrics that happen during the page's lifetime rather than at load time.
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach(entry => {
// process each entry
})
})
observer.observe({ type: 'largest-contentful-paint', buffered: true })
buffered: true is critical. LCP occurs during page load — before your analytics JavaScript has parsed and executed. Without buffered: true, you miss LCP entries that happened before your observer was registered. With it, the browser delivers all past entries immediately upon registration.
Observing Every Web Vital
// LCP
new PerformanceObserver((list) => {
const entries = list.getEntries()
const lcp = entries[entries.length - 1] // latest candidate is the final LCP
console.log('LCP:', lcp.startTime, 'element:', lcp.element)
}).observe({ type: 'largest-contentful-paint', buffered: true })
// CLS
let cumulativeCLS = 0
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) {
cumulativeCLS += entry.value
}
})
}).observe({ type: 'layout-shift', buffered: true })
// INP — full interaction timing breakdown
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
const inputDelay = entry.processingStart - entry.startTime
const processingTime = entry.processingEnd - entry.processingStart
const presentationDelay = entry.duration - processingTime - inputDelay
console.log(`INP breakdown — Input delay: ${inputDelay}ms, Processing: ${processingTime}ms, Presentation: ${presentationDelay}ms`)
})
}).observe({ type: 'event', buffered: true, durationThreshold: 40 })
// Long Tasks — main thread blocking >50ms (includes GC pauses)
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
console.log('Long task:', entry.duration + 'ms at:', entry.startTime)
})
}).observe({ type: 'longtask', buffered: true })
Custom Performance Marks and Measures
For application-specific timing — "how long does the data table take to render after the API responds?" — use custom marks:
performance.mark('table-render-start')
// ... the table renders ...
performance.mark('table-render-end')
performance.measure('table-render-duration', 'table-render-start', 'table-render-end')
const measures = performance.getEntriesByName('table-render-duration')
console.log('Table render:', measures[0].duration + 'ms')
Marks and measures appear in the DevTools Performance panel timeline, correctly positioned alongside browser events. A React hook that wraps this pattern:
function useRenderTiming(componentName) {
const renderStart = useRef(performance.now())
useEffect(() => {
const duration = performance.now() - renderStart.current
performance.measure(`${componentName}-render`, {
start: renderStart.current,
duration,
})
renderStart.current = performance.now() // reset for next render
})
}
Part 3: Building a Real User Monitoring (RUM) System
The Beacon API — Non-Blocking Transmission
The measurement must not affect what is being measured. Sending a large HTTP request during page load affects LCP. The solution is navigator.sendBeacon():
function reportMetric(metric) {
const payload = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
page: window.location.pathname,
timestamp: Date.now(),
// Segmentation dimensions
connection: navigator.connection?.effectiveType ?? 'unknown',
deviceMemory: navigator.deviceMemory ?? 'unknown',
userAgent: navigator.userAgent,
})
// sendBeacon: non-blocking, survives page unload
if (!navigator.sendBeacon('/api/metrics', payload)) {
// Fallback: fetch with keepalive has the same guarantee
fetch('/api/metrics', {
method: 'POST',
body: payload,
keepalive: true,
})
}
}
sendBeacon is guaranteed to complete even if the page is unloading. Normal fetch without keepalive is cancelled during navigation. This matters for metrics like CLS and INP that are only finalised when the user leaves.
Collecting All Vitals
import { onLCP, onCLS, onINP, onFCP, onTTFB } from 'web-vitals'
const sessionStart = Date.now()
function sendToRUM(metric) {
reportMetric({
...metric,
sessionAge: Date.now() - sessionStart, // for memory leak detection
appVersion: process.env.NEXT_PUBLIC_APP_VERSION,
})
}
onLCP(sendToRUM, { reportAllChanges: false })
onCLS(sendToRUM, { reportAllChanges: false })
onINP(sendToRUM, { reportAllChanges: true })
onFCP(sendToRUM)
onTTFB(sendToRUM)
The Collection Endpoint
// app/api/metrics/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const metric = await request.json()
if (!metric.name || typeof metric.value !== 'number') {
return NextResponse.json({ error: 'Invalid' }, { status: 400 })
}
await db.metrics.insert({
name: metric.name,
value: metric.value,
rating: metric.rating,
page: metric.page,
connection: metric.connection,
device_memory: metric.deviceMemory,
app_version: metric.appVersion,
session_age_ms: metric.sessionAge,
timestamp: new Date(),
})
return NextResponse.json({ ok: true })
}
For storage: ClickHouse is the correct choice for performance metrics at scale. It is a columnar database optimised for time-series aggregation — "LCP p75 for /dashboard over the last 7 days, segmented by connection type" runs in milliseconds over billions of rows. Postgres works at small scale but aggregation queries become slow above ~10M rows without careful partitioning.
Part 4: Segmentation — Why Aggregate Numbers Are Misleading
A single aggregate number — "our LCP p75 is 2.1 seconds" — is nearly useless for diagnosis. Consider a bimodal distribution: 74% of users experience 0.8 seconds (desktop, fast network), 26% experience 5.5 seconds (mobile, 3G). The p75 is 2.1 seconds, representing neither group accurately.
Segment every metric by:
Device memory (navigator.deviceMemory): returns approximate RAM in GB (1, 2, 4, 8). A 1GB device parses JavaScript 3-5× slower than an 8GB device and has limited VRAM for compositor layers (FE-06). Users on 1GB devices experience a fundamentally different application.
Connection type (navigator.connection.effectiveType): '4g', '3g', '2g', 'slow-2g'. A 3G user has network-bound performance. A 4G user on a low-end device has compute-bound performance. The fix for each is different.
Geography: derived from IP on your server. A product used primarily in Southeast Asia should be measured from that region. A CDN miss in Singapore has different implications than one in London.
Route/page: /dashboard and /landing have entirely different profiles. Aggregating them hides route-specific problems.
App version: segment by your deployment. If LCP degraded from 1.8s to 3.2s, which deployment caused it?
-- ClickHouse query: p75 LCP for mobile users in India on latest version
SELECT
quantile(0.75)(value) as p75_lcp,
countIf(rating = 'good') / count() * 100 as good_pct
FROM web_vitals
WHERE
metric_name = 'LCP'
AND page = '/dashboard'
AND country = 'IN'
AND connection = '3g'
AND app_version = 'v2.15.0'
AND timestamp >= now() - INTERVAL 7 DAY
This query is what makes performance diagnosis precise rather than speculative.
Part 5: Alerting on Regressions
Do not alert on raw values. Alert on regressions — statistically significant changes from baseline.
// Pseudocode for hourly regression check
async function checkRegression(metricName, page) {
const [baseline, current] = await Promise.all([
db.query(`
SELECT quantile(0.75)(value) as p75
FROM web_vitals
WHERE metric_name = ? AND page = ?
AND timestamp BETWEEN 7 days ago AND 1 day ago
`, [metricName, page]),
db.query(`
SELECT quantile(0.75)(value) as p75
FROM web_vitals
WHERE metric_name = ? AND page = ?
AND timestamp > 1 hour ago
`, [metricName, page])
])
const regression = (current.p75 - baseline.p75) / baseline.p75
if (regression > 0.20) {
sendAlert(`${metricName} regressed ${(regression*100).toFixed(0)}% on ${page}`)
}
}
Tag every metric with app_version set from a build-time environment variable (git commit hash or semver). When a regression alert fires:
SELECT
app_version,
quantile(0.75)(value) as p75_lcp,
min(timestamp) as first_seen
FROM web_vitals
WHERE metric_name = 'LCP' AND page = '/dashboard'
AND timestamp > 3 days ago
GROUP BY app_version
ORDER BY first_seen DESC
If v2.14.0 has p75 of 1.8s and v2.15.0 has p75 of 3.4s, the culprit deployment is identified. Now you trace the regression in DevTools.
Part 6: Memory Monitoring in Production
From FE-07: memory leaks manifest as progressive degradation over session time. The measurement infrastructure connects directly.
// Sample heap size every 5 minutes
const sessionStart = Date.now()
async function sampleMemory() {
if ('measureUserAgentSpecificMemory' in performance) {
try {
const result = await performance.measureUserAgentSpecificMemory()
reportMetric({
name: 'heap-size-mb',
value: result.bytes / 1024 / 1024,
page: window.location.pathname,
sessionAge: Date.now() - sessionStart,
})
} catch (e) { /* cross-origin isolation not met */ }
}
}
setInterval(sampleMemory, 5 * 60 * 1000)
// Collect long tasks for GC pause detection
const longTaskLog = []
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
longTaskLog.push({
duration: entry.duration,
sessionAge: Date.now() - sessionStart,
})
})
}).observe({ type: 'longtask', buffered: true })
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && longTaskLog.length > 0) {
navigator.sendBeacon('/api/metrics', JSON.stringify({
name: 'long-task-session',
count: longTaskLog.length,
maxDuration: Math.max(...longTaskLog.map(t => t.duration)),
tasks: longTaskLog,
page: window.location.pathname,
}))
}
})
The memory leak signature in your database: heap size that grows monotonically with session age, and long task frequency that increases as session age increases. A healthy app's heap oscillates (sawtooth: allocate, collect, allocate, collect). A leaky app's heap grows as a staircase.
Part 7: The Performance Budget in CI
RUM catches regressions after deployment. Performance budgets catch them before.
// lighthouse-budget.json
[{
"path": "/*",
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "total-blocking-time", "budget": 300 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 }
],
"resourceSizes": [
{ "resourceType": "script", "budget": 300 },
{ "resourceType": "total", "budget": 800 }
]
}]
# .github/workflows/performance.yml
- name: Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun \
--collect.url=https://staging.example.com \
--assert.budgetFile=lighthouse-budget.json
Lighthouse budgets are lab measurements — they catch obvious regressions. RUM catches the subtle ones that only appear for specific user populations. Use both: CI budgets for developer feedback loops, RUM for production truth.
Part 8: Connecting Performance to Business Metrics
The final step is correlating technical metrics with business outcomes — this is how performance investment gets prioritised alongside features.
-- Segment users by LCP experience, compare conversion rates
SELECT
CASE
WHEN p75_lcp < 2500 THEN 'good'
WHEN p75_lcp < 4000 THEN 'needs-improvement'
ELSE 'poor'
END as lcp_group,
AVG(conversion_rate) as avg_conversion,
AVG(session_duration_s) as avg_session_duration,
COUNT(DISTINCT user_id) as user_count
FROM (
SELECT
user_id,
quantile(0.75)(value) as p75_lcp
FROM web_vitals
WHERE metric_name = 'LCP'
GROUP BY user_id
) perf
JOIN product_analytics USING (user_id)
GROUP BY lcp_group
ORDER BY lcp_group
If users experiencing poor LCP convert at 2% and users experiencing good LCP convert at 5.5%, the performance investment ROI is immediate and quantifiable. No further argument needed.
Vocabulary Map
| Term | What it physically is |
|---|---|
| Navigation Timing API | A browser API exposing timestamps for every significant page load event (DNS, TCP, TLS, first byte, DOM parse, full load) — the programmatic version of the DevTools Network waterfall, running on real users' machines |
| Resource Timing API | A browser API exposing per-resource timing (DNS, connection, TTFB, download) for every asset the page loaded — allows programmatic identification of slow resources on real user devices |
PerformanceObserver | A browser API for registering callbacks that fire when specific performance entries are created — used to observe LCP, CLS, INP, long tasks, and layout shifts without polling |
buffered: true | A PerformanceObserver.observe() option that delivers all past entries immediately on registration — necessary for metrics like LCP that occur during page load before the observer is registered |
performance.mark() | A browser API that records a named timestamp on the performance timeline — used to instrument application-specific operations for custom timing measurement |
| Real User Monitoring (RUM) | A system collecting performance measurements from actual users via browser APIs — contrasted with Synthetic Monitoring (Lighthouse) which measures in controlled lab conditions |
navigator.sendBeacon() | A browser API sending an HTTP POST non-blocking and guaranteed to complete even during page unload — the correct mechanism for transmitting analytics data without impacting page performance |
keepalive fetch option | A fetch() option ensuring the request completes during page navigation — functionally equivalent to sendBeacon with the ability to set custom request headers |
| p75 | The 75th percentile — the value below which 75% of measurements fall. Google's recommended Web Vitals threshold: "75% of page loads experience this or better" |
| Segmentation | Breaking aggregate metrics by dimensions (device memory, connection type, geography, app version, route) to identify which specific user populations experience performance problems |
| Performance budget | Pre-defined maximum thresholds for metrics enforced during CI — prevents regressions from reaching production by failing builds that exceed budgets |
navigator.connection | A browser API exposing network connection information including effectiveType ('4g', '3g', '2g') and estimated bandwidth — used for metric segmentation by network capability |
navigator.deviceMemory | A browser API returning approximate device RAM in GB — used to segment metrics by device capability class |
| Regression detection | Identifying statistically significant degradation in metrics compared to a baseline — more actionable than threshold alerts because it connects degradation to specific deployments |
measureUserAgentSpecificMemory() | A Chrome 89+ API returning approximate JS heap usage in bytes — requires cross-origin isolation headers, used for production memory leak detection |
| Session age | The elapsed time since the user first loaded the page — used to correlate memory growth and long task frequency with session length to identify memory leaks |
Practice Problems
Problem 1 — Failure Analysis
A team deploys RUM using web-vitals and sendBeacon. After a week, 40% of LCP values in their database are below 100ms — physically impossible (LCP requires at least one network round trip and a paint). The remaining 60% show a reasonable distribution. What are the two most likely causes of the sub-100ms values, and what does each reveal about a gap in their implementation? How would you modify the collection code to filter these?
Problem 2 — Design from Scratch
You are the first engineering hire at a startup with 500 paying customers globally (US, Europe, India). No performance monitoring exists. Design the complete observability stack: what you measure (specific APIs and metrics), how you transmit it, where you store it with justification for the database choice at current and anticipated scale, what dimensions you segment by, and what alerting rules you set. Total infrastructure cost must remain under $100/month. Show your cost estimate.
Problem 3 — Synthesis
Your RUM shows: desktop p75 LCP = 1.2s (good), mobile p75 LCP = 4.8s (poor), desktop p75 INP = 80ms (good), mobile p75 INP = 340ms (needs improvement). Lighthouse score: 94 (excellent). Using FE-03 (bundle parse cost on low-end devices), FE-04 (LCP root causes, INP breakdown), FE-05 (CDN caching), and this chapter (segmentation), construct a complete hypothesis for the mobile performance gap. Describe the DevTools investigation for each hypothesis and the order you would prioritise fixes for maximum improvement per engineering effort.
Problem 4 — Go Deeper
The PerformanceObserver for largest-contentful-paint fires multiple times as larger elements paint. The web-vitals library uses the last entry before first user scroll or interaction as the final LCP. Construct a specific scenario where: (1) a naive implementation taking the first LCP entry produces a falsely optimistic score, (2) the correct implementation taking the last pre-interaction entry produces a correctly pessimistic score, and (3) the difference between them would change which optimisation you prioritise. What does this reveal about the importance of using web-vitals rather than raw PerformanceObserver for production metric collection?
Problem 5 — Previous Chapter Connection (FE-07)
In FE-07 you learned that memory leaks manifest as growing old generation heap and increasingly frequent Major GC pauses. You now have RUM collecting longtask entries and heap size samples. The Long Tasks API does not expose the cause of a long task. Design the specific queries against your RUM database that would allow you to distinguish a JavaScript execution long task (heavy React re-render) from a GC-pause long task in production data. What pattern of values across session age, task frequency, and heap size would confirm a memory leak rather than a one-time heavy computation? Be specific about the query structure and the distinguishing features you would look for.
What's Coming Next
Every chapter in this series has built a domain of understanding: browser physics, Next.js runtime, React rendering, bundle architecture, Web Vitals, rendering performance, caching, memory, and now measurement. FE-09 puts all of it together under pressure. Senior frontend engineering is not tested domain by domain — it is tested as integrated reasoning with novel constraints and no reference materials. The scenarios in FE-09 are the kind you face in interviews at Vercel, Linear, Figma, and Shopify — and in production at 2am when something is wrong and you need to find it fast. The question in each scenario is never "what does X do." It is always "given these constraints and this failure, what do you do, in what order, and why."