Performance Metrics & Monitoring
2026-08-08 · 6 min read
Optimizing performance without measuring it is guesswork. You need metrics that capture specific failure modes — slow loads, interaction jank, layout instability — and a way to collect them from real users, not just a lab machine.
Core Web Vitals
Google's Core Web Vitals are the three metrics that directly capture user experience quality. They're used as ranking signals and are the standard benchmark for web performance.
| Metric | What it measures | Good target |
|---|---|---|
| LCP (Largest Contentful Paint) | When the largest visible element renders | < 2.5s |
| INP (Interaction to Next Paint) | Responsiveness to every user interaction | < 200ms |
| CLS (Cumulative Layout Shift) | Visual stability — content shouldn't jump | < 0.1 |
LCP captures load performance. It measures when the primary content — usually the hero image or largest text block — becomes visible. A slow LCP means the user is staring at a skeleton or blank screen longer than they should. Affected by render-blocking resources, slow server responses, and unoptimized images.
INP captures interaction responsiveness throughout the entire session — not just the first interaction, but every tap, click, and keypress. A feed that loads fast (good LCP) but stutters during scroll and reactions (bad INP) still fails the user. INP is what senior interviewers push on for long-lived, interaction-heavy surfaces. The culprit is almost always long tasks blocking the main thread.
CLS captures visual stability. A score of 0 means nothing moved unexpectedly. The most common causes: images without reserved dimensions, ads that expand after load, and web fonts causing a reflow when they swap in. See Image Optimization for the fixes.
Supporting metrics
Beyond Core Web Vitals, these metrics fill in the picture:
TTFB (Time to First Byte) — how long from navigation start until the first byte of the HTML response arrives. Captures server response time, DNS, TCP, and TLS. A slow TTFB delays everything — the browser can't start parsing until bytes arrive. See Browser Rendering Pipeline for how TTFB fits into the full pipeline.
FCP (First Contentful Paint) — when the browser paints the first text or image. Measures how quickly the user sees something. FCP precedes LCP — FCP is the skeleton or first text, LCP is the primary content.
TBT (Total Blocking Time) — the total time the main thread was blocked by long tasks (>50ms) between FCP and TTI. A lab-only metric (not measurable from real users) but a strong proxy for INP.
Which metric catches which problem
Don't just name metrics — know what failure each one surfaces:
| Symptom | Metric to check |
|---|---|
| Page feels slow to load | LCP, TTFB, FCP |
| Tapping buttons feels laggy | INP |
| Content jumps around while loading | CLS |
| Page loads but feels frozen | TBT |
A page can have excellent LCP and terrible INP — fast to load, painful to use. A page can have excellent INP but terrible CLS — responsive but visually chaotic. Each metric is independent.
Performance techniques for feeds
Interactive feeds are the hardest surface for INP — they're long-lived, continuously updated, and full of user interactions.
Web Workers
Move heavy computation off the main thread. Rich text parsing, mention/hashtag extraction, JSON processing for large payloads — any CPU work that doesn't touch the DOM can run in a Worker:
// main thread
const worker = new Worker('/workers/richtext.js');
worker.postMessage({ rawText: post.body });
worker.onmessage = (e) => renderProcessedText(e.data);
// workers/richtext.js
self.onmessage = ({ data }) => {
const processed = parseRichText(data.rawText); // heavy work, off main thread
self.postMessage(processed);
};
Keeping this off the main thread means user interactions remain responsive while processing runs.
scheduler.postTask()
When you can't move work to a Worker (it touches the DOM), break it into smaller chunks and schedule them with priority hints:
// High priority: visible content
await scheduler.postTask(() => renderAboveFold(), { priority: 'user-blocking' });
// Lower priority: below-fold content, analytics
await scheduler.postTask(() => renderBelowFold(), { priority: 'background' });
user-blocking tasks run before background tasks. The scheduler yields between tasks, giving the browser a chance to process user interactions in between — preventing long tasks from spiking INP.
content-visibility: auto
Skip layout and paint for off-screen content entirely:
.post-card {
content-visibility: auto;
contain-intrinsic-size: 0 300px; /* estimated height to avoid CLS */
}
The browser skips rendering work for elements outside the viewport. For a feed with hundreds of posts, this dramatically reduces the rendering cost of the initial layout pass. contain-intrinsic-size provides a placeholder height so the scrollbar and layout remain correct before the element is rendered.
requestIdleCallback
Run low-priority work — prefetching, analytics logging, non-critical processing — during periods when the browser has nothing more important to do:
requestIdleCallback(() => {
logScrollDepth();
prefetchNextPage();
import('./ReactionPicker'); // tier 3 chunk, see Code Splitting
}, { timeout: 2000 }); // run within 2s even if never truly idle
The timeout option ensures the callback eventually runs even on a busy main thread, preventing indefinite deferral.
Measurement
web-vitals library
Google's web-vitals library reports Core Web Vitals from real browser sessions:
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
fetch('/analytics', {
method: 'POST',
body: JSON.stringify({
name: metric.name, // 'LCP', 'INP', 'CLS', etc.
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
path: location.pathname,
}),
});
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
Each callback fires when the metric value is finalized — LCP on page hide or first user interaction, INP continuously updated throughout the session.
Real User Monitoring (RUM)
Synthetic testing (Lighthouse, PageSpeed Insights) measures performance from a controlled lab environment — fixed hardware, fixed network, no real user behavior. It's useful for catching regressions in CI but doesn't reflect what real users experience.
RUM collects metrics from actual browser sessions and sends them to an analytics pipeline. This lets you:
- Segment by device class — p75 LCP on low-end Android vs MacBook Pro are completely different numbers
- Segment by network type — 4G vs WiFi vs 2G users have different baselines
- Segment by route — the feed page and the settings page have different performance profiles
- Segment by feature flag — measure the performance impact of an A/B test in production
Always measure at the 75th percentile (p75), not the median. Google's Core Web Vitals thresholds are defined at p75 — a page "passes" LCP if 75% of real user sessions see LCP under 2.5s. The median hides the tail; p75 captures users with slower devices and connections.
Performance budgets
Set limits on bundle sizes to catch regressions before they ship:
| Bundle | Budget |
|---|---|
| App shell (Tier 1) | ~100–150 KB gzipped |
| Above-fold interactivity (Tier 2) | ~300 KB gzipped |
| Per-route chunk | ~50 KB gzipped |
Enforce budgets in CI with bundler plugins (webpack's performance.maxAssetSize, Vite's build.chunkSizeWarningLimit, or Bundlesize). A PR that blows the budget fails the build before it ships.