Performance Monitoring
Performance monitoring is the practice of continuously measuring, tracking, and alerting on web performance metrics. Without monitoring, performance degrades silently as new features are added — a phenomenon known as performance entropy.
A complete monitoring strategy combines three pillars: lab testing (Lighthouse, WebPageTest) for controlled benchmarks, real-user monitoring (RUM) for production measurements, and profiling (DevTools, flame charts) for debugging specific issues.
Lighthouse is Google's automated auditing tool for web performance, accessibility, SEO, and best practices. It provides a performance score (0-100) and actionable recommendations. It can be run from Chrome DevTools, CLI, or CI/CD pipelines.
| 1 | # Lighthouse CLI — comprehensive performance audit |
| 2 | npx lighthouse https://example.com \ |
| 3 | --output=html \ |
| 4 | --output-path=./reports/lighthouse.html \ |
| 5 | --only-categories=performance \ |
| 6 | --chrome-flags="--headless --no-sandbox" |
| 7 | |
| 8 | # Run with specific metrics |
| 9 | npx lighthouse https://example.com \ |
| 10 | --only-categories=performance \ |
| 11 | --output=json \ |
| 12 | --preset=desktop |
| 13 | |
| 14 | # CI/CD integration with Lighthouse CI |
| 15 | npm install -g @lhci/cli |
| 16 | |
| 17 | # lhci autorun — runs collect, assert, and upload |
| 18 | lhci autorun |
| 19 | |
| 20 | # lighthouserc.js configuration |
| 21 | module.exports = { |
| 22 | ci: { |
| 23 | collect: { |
| 24 | url: ["http://localhost:3000"], |
| 25 | numberOfRuns: 3, // Average 3 runs for consistency |
| 26 | startServerCommand: "npm run start", |
| 27 | startServerReadyPattern: "ready on", |
| 28 | }, |
| 29 | assert: { |
| 30 | assertions: { |
| 31 | // Core Web Vitals thresholds |
| 32 | "largest-contentful-paint": ["error", { maxNumericValue: 2500 }], |
| 33 | "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }], |
| 34 | "total-blocking-time": ["error", { maxNumericValue: 300 }], |
| 35 | |
| 36 | // Overall score |
| 37 | "categories:performance": ["error", { minScore: 0.9 }], |
| 38 | |
| 39 | // Resource budgets |
| 40 | "resource-summary:script:size": ["error", { maxNumericValue: 300000 }], |
| 41 | "resource-summary:stylesheet:size": ["error", { maxNumericValue: 50000 }], |
| 42 | "resource-summary:image:size": ["error", { maxNumericValue: 500000 }], |
| 43 | "resource-summary:total:size": ["error", { maxNumericValue: 1500000 }], |
| 44 | "resource-summary:third-party:transferSize": ["error", { maxNumericValue: 200000 }], |
| 45 | }, |
| 46 | }, |
| 47 | upload: { |
| 48 | target: "lhci", |
| 49 | serverBaseUrl: "https://lhci.example.com", |
| 50 | }, |
| 51 | }, |
| 52 | }; |
| 1 | // Custom Lighthouse scoring — understand what affects the score |
| 2 | // Lighthouse 10+ scoring weights (approximate): |
| 3 | |
| 4 | // Largest Contentful Paint (LCP) — 25% of score |
| 5 | // Interaction to Next Paint (INP) — 25% of score |
| 6 | // Cumulative Layout Shift (CLS) — 25% of score |
| 7 | // First Contentful Paint (FCP) — 10% of score |
| 8 | // Total Blocking Time (TBT) — 10% of score |
| 9 | // Speed Index — 5% of score |
| 10 | |
| 11 | // Scoring thresholds (Lighthouse 10): |
| 12 | // Good: 90-100 (green) |
| 13 | // Average: 50-89 (orange) |
| 14 | // Poor: 0-49 (red) |
| 15 | |
| 16 | // Parse Lighthouse JSON report |
| 17 | function parseLighthouseReport(reportPath: string) { |
| 18 | const report = JSON.parse(require("fs").readFileSync(reportPath, "utf-8")); |
| 19 | |
| 20 | const metrics = { |
| 21 | score: report.categories.performance.score * 100, |
| 22 | LCP: report.audits["largest-contentful-paint"].numericValue, |
| 23 | CLS: report.audits["cumulative-layout-shift"].numericValue, |
| 24 | FCP: report.audits["first-contentful-paint"].numericValue, |
| 25 | TBT: report.audits["total-blocking-time"].numericValue, |
| 26 | SI: report.audits["speed-index"].numericValue, |
| 27 | }; |
| 28 | |
| 29 | // Get all opportunities (actionable recommendations) |
| 30 | const opportunities = Object.values(report.audits) |
| 31 | .filter((a: any) => a.details?.type === "opportunity" && a.score !== null) |
| 32 | .map((a: any) => ({ |
| 33 | id: a.id, |
| 34 | title: a.title, |
| 35 | savings: a.details.overallSavingsMs, |
| 36 | savingsBytes: a.details.overallSavingsBytes, |
| 37 | })) |
| 38 | .sort((a: any, b: any) => b.savings - a.savings); |
| 39 | |
| 40 | // Get all diagnostics |
| 41 | const diagnostics = Object.values(report.audits) |
| 42 | .filter((a: any) => a.details?.type === "table" && a.score !== null && a.score < 1) |
| 43 | .map((a: any) => ({ |
| 44 | id: a.id, |
| 45 | title: a.title, |
| 46 | displayValue: a.displayValue, |
| 47 | })); |
| 48 | |
| 49 | return { metrics, opportunities, diagnostics }; |
| 50 | } |
info
Real User Monitoring measures performance from actual users in production. Unlike lab tools, RUM captures the full spectrum of devices, networks, and usage patterns. It is the ground truth for user experience.
| 1 | // RUM implementation with Performance Observer API |
| 2 | // rum.js — Real User Monitoring library |
| 3 | |
| 4 | interface MetricData { |
| 5 | name: string; |
| 6 | value: number; |
| 7 | rating: "good" | "needs-improvement" | "poor"; |
| 8 | delta: number; |
| 9 | id: string; |
| 10 | navigationType: string; |
| 11 | attribution?: any; |
| 12 | } |
| 13 | |
| 14 | class RealUserMonitoring { |
| 15 | private endpoint: string; |
| 16 | private userId?: string; |
| 17 | private sessionId: string; |
| 18 | private metrics: Map<string, MetricData> = new Map(); |
| 19 | |
| 20 | constructor(endpoint: string, userId?: string) { |
| 21 | this.endpoint = endpoint; |
| 22 | this.userId = userId; |
| 23 | this.sessionId = this.generateId(); |
| 24 | this.init(); |
| 25 | } |
| 26 | |
| 27 | private init() { |
| 28 | this.observeLCP(); |
| 29 | this.observeFID(); |
| 30 | this.observeCLS(); |
| 31 | this.observeINP(); |
| 32 | this.observeFCP(); |
| 33 | this.observeTTFB(); |
| 34 | this.observeNavigation(); |
| 35 | this.observeResources(); |
| 36 | |
| 37 | // Send metrics when page is hidden (best time to send) |
| 38 | document.addEventListener("visibilitychange", () => { |
| 39 | if (document.visibilityState === "hidden") { |
| 40 | this.sendAllMetrics(); |
| 41 | } |
| 42 | }); |
| 43 | |
| 44 | // Also send on page leave |
| 45 | window.addEventListener("pagehide", () => this.sendAllMetrics()); |
| 46 | } |
| 47 | |
| 48 | private observeLCP() { |
| 49 | const observer = new PerformanceObserver((entryList) => { |
| 50 | const entries = entryList.getEntries(); |
| 51 | const lastEntry = entries[entries.length - 1] as any; |
| 52 | |
| 53 | const value = lastEntry.startTime; |
| 54 | const rating = value <= 2500 ? "good" : value <= 4000 ? "needs-improvement" : "poor"; |
| 55 | |
| 56 | this.metrics.set("LCP", { |
| 57 | name: "LCP", |
| 58 | value, |
| 59 | rating, |
| 60 | delta: value, |
| 61 | id: this.generateId(), |
| 62 | navigationType: "navigate", |
| 63 | attribution: { |
| 64 | element: lastEntry.element?.tagName, |
| 65 | url: lastEntry.url, |
| 66 | size: lastEntry.size, |
| 67 | }, |
| 68 | }); |
| 69 | }); |
| 70 | |
| 71 | observer.observe({ type: "largest-contentful-paint", buffered: true }); |
| 72 | } |
| 73 | |
| 74 | private observeCLS() { |
| 75 | let clsValue = 0; |
| 76 | let sessionValue = 0; |
| 77 | let sessionEntries: any[] = []; |
| 78 | |
| 79 | const observer = new PerformanceObserver((entryList) => { |
| 80 | for (const entry of entryList.getEntries()) { |
| 81 | if ((entry as any).hadRecentInput) continue; |
| 82 | |
| 83 | if (entry.startTime - (sessionEntries[0]?.startTime || 0) < 1000 && |
| 84 | entry.startTime - (sessionEntries[0]?.startTime || 0) < 5000) { |
| 85 | sessionValue += (entry as any).value; |
| 86 | sessionEntries.push(entry); |
| 87 | } else { |
| 88 | sessionValue = (entry as any).value; |
| 89 | sessionEntries = [entry]; |
| 90 | } |
| 91 | |
| 92 | if (sessionValue > clsValue) { |
| 93 | clsValue = sessionValue; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | const rating = clsValue <= 0.1 ? "good" : clsValue <= 0.25 ? "needs-improvement" : "poor"; |
| 98 | |
| 99 | this.metrics.set("CLS", { |
| 100 | name: "CLS", |
| 101 | value: clsValue, |
| 102 | rating, |
| 103 | delta: clsValue, |
| 104 | id: this.generateId(), |
| 105 | navigationType: "navigate", |
| 106 | }); |
| 107 | }); |
| 108 | |
| 109 | observer.observe({ type: "layout-shift", buffered: true }); |
| 110 | } |
| 111 | |
| 112 | private observeINP() { |
| 113 | let maxINP = 0; |
| 114 | |
| 115 | const observer = new PerformanceObserver((entryList) => { |
| 116 | for (const entry of entryList.getEntries()) { |
| 117 | const inpEntry = entry as any; |
| 118 | const duration = inpEntry.processingEnd - inpEntry.startTime; |
| 119 | if (duration > maxINP) { |
| 120 | maxINP = duration; |
| 121 | } |
| 122 | |
| 123 | const rating = maxINP <= 200 ? "good" : maxINP <= 500 ? "needs-improvement" : "poor"; |
| 124 | |
| 125 | this.metrics.set("INP", { |
| 126 | name: "INP", |
| 127 | value: maxINP, |
| 128 | rating, |
| 129 | delta: maxINP, |
| 130 | id: this.generateId(), |
| 131 | navigationType: "navigate", |
| 132 | }); |
| 133 | } |
| 134 | }); |
| 135 | |
| 136 | observer.observe({ type: "event", buffered: true, durationThreshold: 16 }); |
| 137 | } |
| 138 | |
| 139 | private observeFID() { |
| 140 | const observer = new PerformanceObserver((entryList) => { |
| 141 | const firstInput = entryList.getEntries()[0] as any; |
| 142 | if (!firstInput) return; |
| 143 | |
| 144 | const value = firstInput.processingStart - firstInput.startTime; |
| 145 | const rating = value <= 100 ? "good" : value <= 300 ? "needs-improvement" : "poor"; |
| 146 | |
| 147 | this.metrics.set("FID", { |
| 148 | name: "FID", |
| 149 | value, |
| 150 | rating, |
| 151 | delta: value, |
| 152 | id: this.generateId(), |
| 153 | navigationType: "navigate", |
| 154 | }); |
| 155 | }); |
| 156 | |
| 157 | observer.observe({ type: "first-input", buffered: true }); |
| 158 | } |
| 159 | |
| 160 | private observeFCP() { |
| 161 | const observer = new PerformanceObserver((entryList) => { |
| 162 | for (const entry of entryList.getEntries()) { |
| 163 | if (entry.name === "first-contentful-paint") { |
| 164 | const value = entry.startTime; |
| 165 | const rating = value <= 1800 ? "good" : value <= 3000 ? "needs-improvement" : "poor"; |
| 166 | |
| 167 | this.metrics.set("FCP", { |
| 168 | name: "FCP", |
| 169 | value, |
| 170 | rating, |
| 171 | delta: value, |
| 172 | id: this.generateId(), |
| 173 | navigationType: "navigate", |
| 174 | }); |
| 175 | } |
| 176 | } |
| 177 | }); |
| 178 | |
| 179 | observer.observe({ type: "paint", buffered: true }); |
| 180 | } |
| 181 | |
| 182 | private observeTTFB() { |
| 183 | const observer = new PerformanceObserver((entryList) => { |
| 184 | const nav = entryList.getEntries()[0] as PerformanceNavigationTiming; |
| 185 | if (!nav) return; |
| 186 | |
| 187 | const value = nav.responseStart - nav.requestStart; |
| 188 | const rating = value <= 800 ? "good" : value <= 1800 ? "needs-improvement" : "poor"; |
| 189 | |
| 190 | this.metrics.set("TTFB", { |
| 191 | name: "TTFB", |
| 192 | value, |
| 193 | rating, |
| 194 | delta: value, |
| 195 | id: this.generateId(), |
| 196 | navigationType: nav.type, |
| 197 | }); |
| 198 | }); |
| 199 | |
| 200 | observer.observe({ type: "navigation", buffered: true }); |
| 201 | } |
| 202 | |
| 203 | private observeNavigation() { |
| 204 | const observer = new PerformanceObserver((entryList) => { |
| 205 | const nav = entryList.getEntries()[0] as PerformanceNavigationTiming; |
| 206 | if (!nav) return; |
| 207 | |
| 208 | this.metrics.set("Navigation", { |
| 209 | name: "Navigation", |
| 210 | value: nav.loadEventEnd - nav.startTime, |
| 211 | rating: "good", |
| 212 | delta: 0, |
| 213 | id: this.generateId(), |
| 214 | navigationType: nav.type, |
| 215 | attribution: { |
| 216 | redirect: nav.redirectEnd - nav.redirectStart, |
| 217 | dns: nav.domainLookupEnd - nav.domainLookupStart, |
| 218 | tcp: nav.connectEnd - nav.connectStart, |
| 219 | ttfb: nav.responseStart - nav.requestStart, |
| 220 | download: nav.responseEnd - nav.responseStart, |
| 221 | domInteractive: nav.domInteractive - nav.startTime, |
| 222 | domComplete: nav.domComplete - nav.startTime, |
| 223 | loadEvent: nav.loadEventEnd - nav.startTime, |
| 224 | }, |
| 225 | }); |
| 226 | }); |
| 227 | |
| 228 | observer.observe({ type: "navigation", buffered: true }); |
| 229 | } |
| 230 | |
| 231 | private observeResources() { |
| 232 | const observer = new PerformanceObserver((entryList) => { |
| 233 | const slowResources = entryList.getEntries() |
| 234 | .filter((e) => e.duration > 500) |
| 235 | .map((e) => ({ |
| 236 | name: e.name, |
| 237 | duration: e.duration, |
| 238 | size: (e as any).transferSize, |
| 239 | })); |
| 240 | |
| 241 | if (slowResources.length > 0) { |
| 242 | this.metrics.set("SlowResources", { |
| 243 | name: "SlowResources", |
| 244 | value: slowResources.length, |
| 245 | rating: "needs-improvement", |
| 246 | delta: 0, |
| 247 | id: this.generateId(), |
| 248 | navigationType: "navigate", |
| 249 | attribution: slowResources, |
| 250 | }); |
| 251 | } |
| 252 | }); |
| 253 | |
| 254 | observer.observe({ type: "resource", buffered: true }); |
| 255 | } |
| 256 | |
| 257 | private async sendAllMetrics() { |
| 258 | const data = { |
| 259 | userId: this.userId, |
| 260 | sessionId: this.sessionId, |
| 261 | url: window.location.href, |
| 262 | userAgent: navigator.userAgent, |
| 263 | connection: (navigator as any).connection?.effectiveType, |
| 264 | deviceMemory: (navigator as any).deviceMemory, |
| 265 | hardwareConcurrency: navigator.hardwareConcurrency, |
| 266 | timestamp: Date.now(), |
| 267 | metrics: Object.fromEntries(this.metrics), |
| 268 | }; |
| 269 | |
| 270 | // Use sendBeacon for reliable delivery during page unload |
| 271 | if (navigator.sendBeacon) { |
| 272 | navigator.sendBeacon(this.endpoint, JSON.stringify(data)); |
| 273 | } else { |
| 274 | fetch(this.endpoint, { |
| 275 | method: "POST", |
| 276 | body: JSON.stringify(data), |
| 277 | keepalive: true, |
| 278 | }); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | private generateId(): string { |
| 283 | return Math.random().toString(36).substring(2, 15); |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | // Initialize RUM |
| 288 | const rum = new RealUserMonitoring( |
| 289 | "https://rum.example.com/collect", |
| 290 | undefined // userId (optional) |
| 291 | ); |
| RUM Provider | Free Tier | Features | Best For |
|---|---|---|---|
| web-vitals (npm) | Free (self-hosted) | CWV only, lightweight | Simple integration, custom backends |
| Vercel Analytics | Included in plans | CWV, Speed Insights, dashboard | Next.js / Vercel projects |
| Google Analytics 4 | Free | Custom events, segments | General analytics + perf |
| SpeedCurve | Trial available | Budgets, alerts, competitor tracking | Enterprise performance monitoring |
| Datadog RUM | 14-day trial | Full observability, errors, traces | Full-stack observability |
best practice
Performance budgets set hard limits on metrics that affect performance. They are enforced in CI/CD to prevent regressions from reaching production. Without budgets, performance degrades by 1-2% per sprint as features are added.
| 1 | // size-limit — JavaScript bundle size budgets |
| 2 | // .size-limit.json |
| 3 | [ |
| 4 | { |
| 5 | "name": "Initial bundle", |
| 6 | "path": "dist/index.js", |
| 7 | "import": "{ App }", |
| 8 | "limit": "120 KB", |
| 9 | "gzip": true |
| 10 | }, |
| 11 | { |
| 12 | "name": "Vendor chunk", |
| 13 | "path": "dist/vendor.js", |
| 14 | "limit": "200 KB", |
| 15 | "gzip": true, |
| 16 | "ignore": ["react", "react-dom"] |
| 17 | }, |
| 18 | { |
| 19 | "name": "CSS bundle", |
| 20 | "path": "dist/styles.css", |
| 21 | "limit": "30 KB", |
| 22 | "gzip": true |
| 23 | } |
| 24 | ] |
| 25 | |
| 26 | // package.json scripts |
| 27 | // { |
| 28 | // "scripts": { |
| 29 | // "size": "size-limit", |
| 30 | // "size:why": "size-limit --why" |
| 31 | // } |
| 32 | // } |
| 33 | |
| 34 | // bundlesize — CI bundle size checks |
| 35 | // package.json |
| 36 | // { |
| 37 | // "bundlesize": [ |
| 38 | // { "path": "./dist/**/*.js", "maxSize": "50 kB", "compression": "gzip" }, |
| 39 | // { "path": "./dist/**/*.css", "maxSize": "20 kB", "compression": "gzip" } |
| 40 | // ] |
| 41 | // } |
| 42 | |
| 43 | // GitHub Actions — performance budget enforcement |
| 44 | // .github/workflows/perf-budget.yml |
| 45 | // name: Performance Budget |
| 46 | // on: [pull_request] |
| 47 | // jobs: |
| 48 | // budget: |
| 49 | // runs-on: ubuntu-latest |
| 50 | // steps: |
| 51 | // - uses: actions/checkout@v4 |
| 52 | // - uses: actions/setup-node@v4 |
| 53 | // - run: npm ci |
| 54 | // - run: npm run build |
| 55 | // - run: npx size-limit |
| 56 | |
| 57 | // Lighthouse CI budgets (in lighthouserc.js) |
| 58 | // assert: { |
| 59 | // assertions: { |
| 60 | // "categories:performance": ["error", { minScore: 0.9 }], |
| 61 | // "resource-summary:script:size": ["error", { maxNumericValue: 300000 }], |
| 62 | // } |
| 63 | // } |
| Metric | Budget | Rationale |
|---|---|---|
| Total JS (gzip) | ≤ 170KB | Sub-second parse on mid-range phones |
| Total CSS (gzip) | ≤ 30KB | Fast style calculation, minimal render-blocking |
| Total page weight | ≤ 1.5MB | 3G load in under 5 seconds |
| Third-party JS | ≤ 100KB | Third-party scripts are uncontrolled |
| Image weight | ≤ 500KB | Above-the-fold images with lazy loading |
| Request count | ≤ 50 | Minimize HTTP overhead |
| LCP | ≤ 2.5s | Google Core Web Vitals threshold |
| INP | ≤ 200ms | Responsive interactivity |
| CLS | ≤ 0.1 | Visual stability |
pro tip
Chrome DevTools provides the most detailed performance profiling capabilities. The Performance panel records everything happening in the browser — JavaScript execution, layout, paint, and compositing — and visualizes it as a flame chart.
| 1 | Chrome DevTools Performance Panel — Key Features: |
| 2 | |
| 3 | 1. Recording |
| 4 | - Click record, interact with page, stop recording |
| 5 | - Enable "Screenshots" for visual timeline |
| 6 | - Enable "Memory" for heap snapshot comparison |
| 7 | - Throttle CPU (4x or 6x) to simulate mid-range devices |
| 8 | - Throttle network (Slow 3G) to simulate slow connections |
| 9 | |
| 10 | 2. Flame Chart (Main Thread) |
| 11 | - X-axis: Time (left to right) |
| 12 | - Y-axis: Call stack depth |
| 13 | - Color coding: |
| 14 | - Yellow: JavaScript execution |
| 15 | - Purple: Layout (recalculation) |
| 16 | - Green: Paint |
| 17 | - Blue: Composite (GPU) |
| 18 | - Wide blocks = long tasks = jank sources |
| 19 | |
| 20 | 3. Summary Tab |
| 21 | - Breakdown of time spent in each category |
| 22 | - Shows total: Scripting, Rendering, Painting, System |
| 23 | |
| 24 | 4. Bottom-Up Tab |
| 25 | - Shows which functions took the most total time |
| 26 | - Group by "Total Time" to find bottlenecks |
| 27 | |
| 28 | 5. Call Tree Tab |
| 29 | - Hierarchical view of function calls |
| 30 | - Self-time vs total time columns |
| 31 | |
| 32 | 6. Long Tasks |
| 33 | - Red triangles in the timeline indicate long tasks (>50ms) |
| 34 | - Click on a long task to see what code was executing |
| 35 | - Long tasks block the main thread and cause jank |
| 36 | |
| 37 | 7. Memory Panel |
| 38 | - Heap size over time (should return to baseline after GC) |
| 39 | - Take heap snapshots to find memory leaks |
| 40 | - Compare snapshots to see what objects are growing |
| 1 | // Programmatic long task detection |
| 2 | function detectLongTasks() { |
| 3 | const longTasks: { duration: number; startTime: number; scripts: string[] }[] = []; |
| 4 | |
| 5 | const observer = new PerformanceObserver((entryList) => { |
| 6 | for (const entry of entryList.getEntries()) { |
| 7 | if (entry.duration > 50) { |
| 8 | const longTask = { |
| 9 | duration: entry.duration, |
| 10 | startTime: entry.startTime, |
| 11 | scripts: [], // Attribution data when available |
| 12 | }; |
| 13 | |
| 14 | // Get attribution (which scripts caused the long task) |
| 15 | if ((entry as any).attribution) { |
| 16 | longTask.scripts = (entry as any).attribution.map( |
| 17 | (a: any) => a.scriptURL || "unknown" |
| 18 | ); |
| 19 | } |
| 20 | |
| 21 | longTasks.push(longTask); |
| 22 | |
| 23 | console.warn( |
| 24 | "Long task detected:", |
| 25 | entry.duration.toFixed(1) + "ms", |
| 26 | "at", entry.startTime.toFixed(0) + "ms", |
| 27 | longTask.scripts.length > 0 ? "caused by: " + longTask.scripts.join(", ") : "" |
| 28 | ); |
| 29 | } |
| 30 | } |
| 31 | }); |
| 32 | |
| 33 | observer.observe({ type: "longtask", buffered: true }); |
| 34 | |
| 35 | return { |
| 36 | getTasks: () => longTasks, |
| 37 | getSummary: () => ({ |
| 38 | count: longTasks.length, |
| 39 | totalBlockingTime: longTasks.reduce( |
| 40 | (sum, t) => sum + Math.max(0, t.duration - 50), 0 |
| 41 | ), |
| 42 | worstTask: Math.max(0, ...longTasks.map((t) => t.duration)), |
| 43 | }), |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | // Memory leak detection |
| 48 | function detectMemoryLeaks() { |
| 49 | const snapshots: { timestamp: number; heapSize: number }[] = []; |
| 50 | |
| 51 | if ("memory" in performance) { |
| 52 | const mem = (performance as any).memory; |
| 53 | snapshots.push({ |
| 54 | timestamp: Date.now(), |
| 55 | heapSize: mem.usedJSHeapSize, |
| 56 | }); |
| 57 | |
| 58 | setInterval(() => { |
| 59 | const current = { |
| 60 | timestamp: Date.now(), |
| 61 | heapSize: mem.usedJSHeapSize, |
| 62 | }; |
| 63 | snapshots.push(current); |
| 64 | |
| 65 | // Check for steady growth (potential leak) |
| 66 | if (snapshots.length > 10) { |
| 67 | const recent = snapshots.slice(-10); |
| 68 | const growth = recent[recent.length - 1].heapSize - recent[0].heapSize; |
| 69 | const avgGrowth = growth / recent.length; |
| 70 | |
| 71 | if (avgGrowth > 100000) { // >100KB per sample |
| 72 | console.warn("Potential memory leak: avg growth", |
| 73 | (avgGrowth / 1024).toFixed(1) + "KB/sample"); |
| 74 | } |
| 75 | } |
| 76 | }, 5000); |
| 77 | } |
| 78 | } |
info
Flame charts are the primary visualization for performance profiling. Understanding how to read them is essential for identifying bottlenecks. Each horizontal block represents a function call — wider blocks mean more time spent.
| 1 | Flame Chart Reading Guide: |
| 2 | |
| 3 | Top-level layout: |
| 4 | ┌──────────────────────────────────────────────────┐ |
| 5 | │ Navigation / Raster │ ← Browser threads |
| 6 | ├──────────────────────────────────────────────────┤ |
| 7 | │ Main Thread │ ← Your JS + layout + paint |
| 8 | │ ┌─────────────────────────────────────────────┐ │ |
| 9 | │ │ Scripting (yellow) │ │ ← JS execution |
| 10 | │ │ ┌──────────────────────────────────────────┐│ │ |
| 11 | │ │ │ Function A (50ms) ││ │ |
| 12 | │ │ │ ┌──────────────────────────────────────┐ ││ │ |
| 13 | │ │ │ │ Function B (30ms) │ ││ │ |
| 14 | │ │ │ │ ┌────────────────────────────────┐ │ ││ │ |
| 15 | │ │ │ │ │ Function C (15ms) │ │ ││ │ |
| 16 | │ │ │ │ │ ┌──────────────────────────┐ │ │ ││ │ |
| 17 | │ │ │ │ │ │ Function D (5ms) │ │ │ ││ │ |
| 18 | │ │ │ │ │ └──────────────────────────┘ │ │ ││ │ |
| 19 | │ │ │ │ └────────────────────────────────┘ │ ││ │ |
| 20 | │ │ │ └──────────────────────────────────────┘ ││ │ |
| 21 | │ │ └──────────────────────────────────────────┘│ │ |
| 22 | │ ├─────────────────────────────────────────────┤ │ |
| 23 | │ │ Layout (purple) │ │ ← Style + layout |
| 24 | │ ├─────────────────────────────────────────────┤ │ |
| 25 | │ │ Paint (green) │ │ ← Rasterization |
| 26 | │ ├─────────────────────────────────────────────┤ │ |
| 27 | │ │ Composite (blue) │ │ ← GPU layer composition |
| 28 | │ └─────────────────────────────────────────────┘ │ |
| 29 | └──────────────────────────────────────────────────┘ |
| 30 | |
| 31 | Key patterns to look for: |
| 32 | |
| 33 | 1. Long yellow blocks (Scripting) |
| 34 | → JavaScript is blocking the main thread |
| 35 | → Look for: heavy computations, DOM manipulation, event handlers |
| 36 | → Fix: break up long tasks, use web workers, debouncing |
| 37 | |
| 38 | 2. Tall purple blocks (Layout) |
| 39 | → Expensive layout recalculation |
| 40 | → Look for: many DOM elements, forced reflows, complex CSS |
| 41 | → Fix: reduce DOM size, use CSS containment, batch DOM writes |
| 42 | |
| 43 | 3. Wide green blocks (Paint) |
| 44 | → Expensive rendering |
| 45 | → Look for: large areas, blur filters, box shadows |
| 46 | → Fix: reduce paint area, use transform/opacity, GPU layers |
| 47 | |
| 48 | 4. Red triangles on top (Long Tasks) |
| 49 | → Tasks exceeding 50ms that block the main thread |
| 50 | → These directly impact INP and user responsiveness |
| 51 | → Fix: break up with setTimeout, scheduler.yield(), web workers |
| 52 | |
| 53 | 5. Gaps between blocks (Idle time) |
| 54 | → Good! Main thread is not overloaded |
| 55 | → If you see this during interaction, the issue is elsewhere |
| 56 | |
| 57 | 6. Repeated identical patterns (Loops) |
| 58 | → Same code running repeatedly |
| 59 | → Check if it is intentional or a bug |
| 60 | → Consider caching or memoization |
| 1 | // Programmatic profiling with Performance API |
| 2 | class PerformanceProfiler { |
| 3 | private marks: Map<string, number> = new Map(); |
| 4 | private measures: { name: string; duration: number; start: number }[] = []; |
| 5 | |
| 6 | mark(name: string) { |
| 7 | performance.mark(name); |
| 8 | this.marks.set(name, performance.now()); |
| 9 | } |
| 10 | |
| 11 | measure(name: string, startMark: string, endMark?: string) { |
| 12 | const end = endMark || name + "-end"; |
| 13 | if (!this.marks.has(end)) { |
| 14 | performance.mark(end); |
| 15 | } |
| 16 | |
| 17 | performance.measure(name, startMark, end); |
| 18 | const measure = performance.getEntriesByName(name).pop(); |
| 19 | |
| 20 | if (measure) { |
| 21 | this.measures.push({ |
| 22 | name, |
| 23 | duration: measure.duration, |
| 24 | start: measure.startTime, |
| 25 | }); |
| 26 | |
| 27 | if (measure.duration > 16) { // Longer than one frame |
| 28 | console.warn("Slow operation:", name, measure.duration.toFixed(1) + "ms"); |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | return measure?.duration || 0; |
| 33 | } |
| 34 | |
| 35 | // Profile a function |
| 36 | async profile<T>(name: string, fn: () => T | Promise<T>): Promise<T> { |
| 37 | this.mark(name + "-start"); |
| 38 | const result = await fn(); |
| 39 | this.measure(name, name + "-start", name + "-end"); |
| 40 | return result; |
| 41 | } |
| 42 | |
| 43 | // Get summary of all measurements |
| 44 | getSummary() { |
| 45 | const sorted = [...this.measures].sort((a, b) => b.duration - a.duration); |
| 46 | const total = sorted.reduce((sum, m) => sum + m.duration, 0); |
| 47 | |
| 48 | console.table(sorted.map((m) => ({ |
| 49 | name: m.name, |
| 50 | duration: m.duration.toFixed(1) + "ms", |
| 51 | percentage: ((m.duration / total) * 100).toFixed(1) + "%", |
| 52 | }))); |
| 53 | |
| 54 | return { measures: sorted, total }; |
| 55 | } |
| 56 | |
| 57 | // Clear all marks and measures |
| 58 | clear() { |
| 59 | this.marks.clear(); |
| 60 | this.measures = []; |
| 61 | performance.clearMarks(); |
| 62 | performance.clearMeasures(); |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // Usage |
| 67 | const profiler = new PerformanceProfiler(); |
| 68 | |
| 69 | // Profile an async operation |
| 70 | const data = await profiler.profile("fetch-products", async () => { |
| 71 | const res = await fetch("/api/products"); |
| 72 | return res.json(); |
| 73 | }); |
| 74 | |
| 75 | // Profile a synchronous operation |
| 76 | profiler.profile("render-list", () => { |
| 77 | renderProductList(data); |
| 78 | }); |
| 79 | |
| 80 | // Get summary after all operations |
| 81 | profiler.getSummary(); |
CrUX is Google's dataset of real-user Core Web Vitals data for millions of websites. It is the source of data for PageSpeed Insights and the basis for Google's search ranking signals. CrUX data is collected from Chrome users who opt in to usage statistics.
| 1 | // CrUX API — fetch real-user metrics for your origin |
| 2 | async function fetchCrUXData(origin: string) { |
| 3 | const API_KEY = "YOUR_CRUX_API_KEY"; |
| 4 | |
| 5 | const response = await fetch( |
| 6 | "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=" + API_KEY, |
| 7 | { |
| 8 | method: "POST", |
| 9 | headers: { "Content-Type": "application/json" }, |
| 10 | body: JSON.stringify({ url: origin }), |
| 11 | } |
| 12 | ); |
| 13 | |
| 14 | const data = await response.json(); |
| 15 | |
| 16 | // Parse Core Web Vitals from CrUX response |
| 17 | const metrics = { |
| 18 | LCP: { |
| 19 | p75: data.record.metrics.largestContentfulPaint?.percentiles?.p75, |
| 20 | distributions: data.record.metrics.largestContentfulPaint?.distributions, |
| 21 | // distributions: [{min, max, proportion}, ...] |
| 22 | // proportion is % of page loads in this range |
| 23 | }, |
| 24 | INP: { |
| 25 | p75: data.record.metrics.interactionToNextPaint?.percentiles?.p75, |
| 26 | distributions: data.record.metrics.interactionToNextPaint?.distributions, |
| 27 | }, |
| 28 | CLS: { |
| 29 | p75: data.record.metrics.cumulativeLayoutShift?.percentiles?.p75, |
| 30 | distributions: data.record.metrics.cumulativeLayoutShift?.distributions, |
| 31 | }, |
| 32 | FCP: { |
| 33 | p75: data.record.metrics.firstContentfulPaint?.percentiles?.p75, |
| 34 | }, |
| 35 | TTFB: { |
| 36 | p75: data.record.metrics.experimentalTimeToFirstByte?.percentiles?.p75, |
| 37 | }, |
| 38 | }; |
| 39 | |
| 40 | // Calculate "good" percentage (percentage of page loads with good CWV) |
| 41 | const lcpGood = metrics.LCP.distributions?.[0]?.proportion || 0; |
| 42 | const inpGood = metrics.INP.distributions?.[0]?.proportion || 0; |
| 43 | const clsGood = metrics.CLS.distributions?.[0]?.proportion || 0; |
| 44 | |
| 45 | const goodCWV = Math.min(lcpGood, inpGood, clsGood); |
| 46 | |
| 47 | console.log("CrUX Data for", origin); |
| 48 | console.log(" LCP p75:", (metrics.LCP.p75 / 1000).toFixed(1) + "s"); |
| 49 | console.log(" INP p75:", metrics.INP.p75 + "ms"); |
| 50 | console.log(" CLS p75:", metrics.CLS.p75.toFixed(3)); |
| 51 | console.log(" Good CWV:", (goodCWV * 100).toFixed(1) + "%"); |
| 52 | |
| 53 | return { metrics, goodCWV }; |
| 54 | } |
| 55 | |
| 56 | // BigQuery — historical CrUX data |
| 57 | // Query: SELECT * FROM `chrome-ux-report.all.202401` |
| 58 | // WHERE origin = 'https://example.com' |
| 59 | |
| 60 | // CrUX Dashboard — visual trends over time |
| 61 | // https://developer.chrome.com/docs/crux/ |
warning
Monitoring without alerting is just dashboards that nobody looks at. Set up automated alerts for performance regressions so the team is notified before users complain. Alert on both synthetic (Lighthouse CI) and real-user (RUM) metrics.
| 1 | // Performance alert configuration |
| 2 | // Alert when Core Web Vitals degrade in production |
| 3 | |
| 4 | interface AlertRule { |
| 5 | metric: string; |
| 6 | threshold: number; |
| 7 | window: string; // e.g., "1h", "24h", "7d" |
| 8 | severity: "warning" | "critical"; |
| 9 | notify: string[]; |
| 10 | } |
| 11 | |
| 12 | const alertRules: AlertRule[] = [ |
| 13 | { |
| 14 | metric: "LCP_p75", |
| 15 | threshold: 2500, // Alert if p75 exceeds 2.5s |
| 16 | window: "1h", |
| 17 | severity: "warning", |
| 18 | notify: ["slack:#performance", "email:team@example.com"], |
| 19 | }, |
| 20 | { |
| 21 | metric: "LCP_p75", |
| 22 | threshold: 4000, // Critical if p75 exceeds 4s |
| 23 | window: "1h", |
| 24 | severity: "critical", |
| 25 | notify: ["slack:#performance", "pagerduty:oncall"], |
| 26 | }, |
| 27 | { |
| 28 | metric: "INP_p75", |
| 29 | threshold: 200, |
| 30 | window: "1h", |
| 31 | severity: "warning", |
| 32 | notify: ["slack:#performance"], |
| 33 | }, |
| 34 | { |
| 35 | metric: "CLS_p75", |
| 36 | threshold: 0.1, |
| 37 | window: "24h", |
| 38 | severity: "warning", |
| 39 | notify: ["slack:#performance"], |
| 40 | }, |
| 41 | { |
| 42 | metric: "bundle_size_bytes", |
| 43 | threshold: 300000, // Alert if JS bundle exceeds 300KB gzipped |
| 44 | window: "deploy", |
| 45 | severity: "critical", |
| 46 | notify: ["slack:#engineering", "github:pr-comment"], |
| 47 | }, |
| 48 | ]; |
| 49 | |
| 50 | // Simple alerting function |
| 51 | function checkAlerts(currentMetrics: Record<string, number>) { |
| 52 | const alerts: { rule: AlertRule; currentValue: number }[] = []; |
| 53 | |
| 54 | for (const rule of alertRules) { |
| 55 | const currentValue = currentMetrics[rule.metric]; |
| 56 | if (currentValue !== undefined && currentValue > rule.threshold) { |
| 57 | alerts.push({ rule, currentValue }); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | for (const alert of alerts) { |
| 62 | const message = |
| 63 | "[" + alert.rule.severity.toUpperCase() + "] " + |
| 64 | alert.rule.metric + " = " + alert.currentValue + |
| 65 | " (threshold: " + alert.rule.threshold + ")"; |
| 66 | |
| 67 | console.warn(message); |
| 68 | |
| 69 | // Send to notification channels |
| 70 | for (const channel of alert.rule.notify) { |
| 71 | sendNotification(channel, message); |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | return alerts; |
| 76 | } |
| 77 | |
| 78 | function sendNotification(channel: string, message: string) { |
| 79 | // Implementation depends on channel type |
| 80 | // Slack webhook, email, PagerDuty API, GitHub API, etc. |
| 81 | console.log("Alert sent to", channel, ":", message); |
| 82 | } |
best practice