|$ curl https://forge-ai.dev/api/markdown?path=docs/performance
$cat docs/web-performance.md
updated Recently·40 min read·published

Web Performance

PerformanceOptimizationCore Web VitalsBeginner to Advanced🎯Free Tools
Introduction

Web performance is the discipline of making websites load fast, respond quickly, and feel smooth. It directly impacts user experience, conversion rates, and search engine rankings. Google found that a 1-second delay in mobile load time can reduce conversions by up to 20%.

Performance is not a single metric — it is a collection of measurements covering loading speed, interactivity, visual stability, and rendering efficiency. Modern performance optimization requires understanding the browser rendering pipeline, network behavior, and user-perceived metrics.

Core Web Vitals

Core Web Vitals are a set of three metrics that Google considers critical for user experience. They are the foundation of performance measurement and directly influence search engine rankings through the Page Experience signal.

Largest Contentful Paint (LCP)

LCP measures when the largest content element in the viewport becomes visible. This could be a hero image, a heading, or a block of text. It represents how quickly the main content loads. Good LCP is under 2.5 seconds.

lcp-measurement.ts
TypeScript
1// Measuring LCP with Performance Observer
2function observeLCP() {
3 let lcpValue = 0;
4
5 const observer = new PerformanceObserver((entryList) => {
6 const entries = entryList.getEntries();
7 const lastEntry = entries[entries.length - 1] as PerformanceEntry & {
8 renderTime: number;
9 loadTime: number;
10 size: number;
11 element: Element;
12 url: string;
13 };
14 lcpValue = lastEntry.startTime;
15 console.log("LCP:", lcpValue, "ms");
16 console.log("LCP Element:", lastEntry.element);
17 console.log("LCP Resource:", lastEntry.url);
18 });
19
20 observer.observe({ type: "largest-contentful-paint", buffered: true });
21 return () => observer.disconnect();
22}
23
24// Send LCP to analytics
25function sendLCPMetric(metric: { value: number; element?: string }) {
26 if (navigator.sendBeacon) {
27 navigator.sendBeacon("/api/metrics", JSON.stringify({
28 name: "LCP",
29 value: metric.value,
30 element: metric.element,
31 page: window.location.pathname,
32 timestamp: Date.now(),
33 }));
34 }
35}

Interaction to Next Paint (INP)

INP measures the latency of all interactions throughout the page lifecycle. It captures the delay between a user input (click, tap, keypress) and the next visual update. Good INP is under 200 milliseconds. INP replaced First Input Delay (FID) in March 2024.

inp-measurement.ts
TypeScript
1// Measuring INP with Performance Observer
2function observeINP() {
3 let maxINP = 0;
4 let maxINPTarget: EventTarget | null = null;
5
6 const observer = new PerformanceObserver((entryList) => {
7 const entries = entryList.getEntries();
8 entries.forEach((entry) => {
9 const inpEntry = entry as PerformanceEntry & {
10 processingStart: number;
11 processingEnd: number;
12 startTime: number;
13 target: EventTarget;
14 };
15 const duration = inpEntry.processingEnd - inpEntry.startTime;
16 if (duration > maxINP) {
17 maxINP = duration;
18 maxINPTarget = inpEntry.target;
19 }
20 });
21 });
22
23 observer.observe({ type: "event", buffered: true, durationThreshold: 16 });
24
25 // Report on page hide
26 document.addEventListener("visibilitychange", () => {
27 if (document.visibilityState === "hidden") {
28 const selector = maxINPTarget
29 ? getSelector(maxINPTarget)
30 : "unknown";
31 sendMetric({ name: "INP", value: maxINP, target: selector });
32 }
33 });
34}
35
36function getSelector(el: EventTarget): string {
37 if (el instanceof Element) {
38 return el.tagName.toLowerCase() + (el.id ? '#' + el.id : '')
39 + (el.className ? '.' + String(el.className).split(' ')[0] : '');
40 }
41 return "unknown";
42}

Cumulative Layout Shift (CLS)

CLS measures the sum of all unexpected layout shifts during the entire page lifecycle. Layout shifts happen when elements move unexpectedly, causing users to misclick or lose their reading position. Good CLS is under 0.1.

cls-measurement.ts
TypeScript
1// Measuring CLS with Performance Observer
2function observeCLS() {
3 let clsScore = 0;
4 let sessionValue = 0;
5 let sessionEntries: PerformanceEntry[] = [];
6
7 const observer = new PerformanceObserver((entryList) => {
8 const entries = entryList.getEntries() as (PerformanceEntry & {
9 value: number;
10 hadRecentInput: boolean;
11 lastEntryId: number;
12 })[];
13
14 entries.forEach((entry) => {
15 if (entry.hadRecentInput) return; // Ignore user-triggered shifts
16
17 if (entry.startTime - sessionEntries[0]?.startTime < 1000 &&
18 entry.startTime - sessionEntries[0]?.startTime < 5000) {
19 sessionValue += entry.value;
20 sessionEntries.push(entry);
21 } else {
22 sessionValue = entry.value;
23 sessionEntries = [entry];
24 }
25
26 if (sessionValue > clsScore) {
27 clsScore = sessionValue;
28 }
29 });
30 });
31
32 observer.observe({ type: "layout-shift", buffered: true });
33
34 document.addEventListener("visibilitychange", () => {
35 if (document.visibilityState === "hidden") {
36 sendMetric({ name: "CLS", value: clsScore });
37 }
38 });
39}
MetricGoodNeeds ImprovementPoorMeasures
LCP≤ 2.5s2.5s - 4.0s> 4.0sLoading performance
INP≤ 200ms200ms - 500ms> 500msInteractivity
CLS≤ 0.10.1 - 0.25> 0.25Visual stability
Additional Performance Metrics

Beyond Core Web Vitals, several other metrics provide insight into specific aspects of performance. These are useful for diagnosing issues and tracking specific optimizations.

additional-metrics.ts
TypeScript
1// Time to First Byte (TTFB) — server response time
2// Measures the time from navigation start to first byte received
3const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
4const ttfb = navigation.responseStart - navigation.requestStart;
5console.log("TTFB:", ttfb, "ms");
6
7// First Contentful Paint (FCP) — when first DOM content renders
8const fcp = performance.getEntriesByName("first-contentful-paint")[0];
9console.log("FCP:", fcp.startTime, "ms");
10
11// Total Blocking Time (TBT) — sum of long tasks during FCP to TTI
12// A long task is any task taking longer than 50ms
13function measureTBT() {
14 let tbt = 0;
15 const observer = new PerformanceObserver((list) => {
16 list.getEntries().forEach((entry) => {
17 if (entry.duration > 50) {
18 tbt += entry.duration - 50;
19 }
20 });
21 });
22 observer.observe({ type: "longtask", buffered: true });
23 setTimeout(() => {
24 observer.disconnect();
25 console.log("TBT:", tbt, "ms");
26 }, 10000);
27}
28
29// First Input Delay (FID) — time from first input to event handler execution
30function observeFID() {
31 const observer = new PerformanceObserver((entryList) => {
32 const firstInput = entryList.getEntries()[0] as PerformanceEntry & {
33 processingStart: number;
34 };
35 const fid = firstInput.processingStart - firstInput.startTime;
36 console.log("FID:", fid, "ms");
37 });
38 observer.observe({ type: "first-input", buffered: true });
39}
40
41// Speed Index — how quickly content is visually displayed
42// Requires WebPageTest or Lighthouse (not available via JS API)
43
44// Total Page Weight — sum of all resources
45function measurePageWeight() {
46 const resources = performance.getEntriesByType("resource");
47 let totalBytes = 0;
48 resources.forEach((r) => {
49 totalBytes += (r as PerformanceResourceTiming).transferSize;
50 });
51 console.log("Page weight:", (totalBytes / 1024).toFixed(1), "KB");
52 console.log("Resource count:", resources.length);
53}

info

TBT (Total Blocking Time) is the lab equivalent of INP (Interaction to Next Paint). Both measure main thread blocking, but TBT is measured in controlled lab conditions while INP is measured from real users. Optimize for both.
Browser Performance APIs

The browser provides a rich set of APIs for measuring performance. Understanding these APIs is essential for building custom performance monitoring and diagnosing issues.

performance-apis.ts
TypeScript
1// PerformanceObserver — the primary API for performance monitoring
2const observer = new PerformanceObserver((list) => {
3 for (const entry of list.getEntries()) {
4 switch (entry.entryType) {
5 case "largest-contentful-paint":
6 console.log("LCP:", entry.startTime);
7 break;
8 case "layout-shift":
9 console.log("CLS shift:", (entry as any).value);
10 break;
11 case "first-input":
12 console.log("FID:", (entry as any).processingStart - entry.startTime);
13 break;
14 case "navigation":
15 const nav = entry as PerformanceNavigationTiming;
16 console.log("DNS:", nav.domainLookupEnd - nav.domainLookupStart);
17 console.log("TCP:", nav.connectEnd - nav.connectStart);
18 console.log("TTFB:", nav.responseStart - nav.requestStart);
19 console.log("DOMContentLoaded:", nav.domContentLoadedEventEnd - nav.startTime);
20 console.log("Load:", nav.loadEventEnd - nav.startTime);
21 break;
22 case "resource":
23 const res = entry as PerformanceResourceTiming;
24 console.log(res.name, res.duration.toFixed(0) + "ms",
25 (res.transferSize / 1024).toFixed(1) + "KB");
26 break;
27 case "longtask":
28 console.log("Long task:", entry.duration.toFixed(0) + "ms",
29 "at", entry.startTime.toFixed(0) + "ms");
30 break;
31 case "paint":
32 console.log(entry.name + ":", entry.startTime + "ms");
33 break;
34 }
35 }
36});
37
38// Observe all entry types
39observer.observe({
40 type: "largest-contentful-paint",
41 buffered: true,
42});
43
44// performance.measure() — custom timing measurements
45performance.mark("app-init-start");
46// ... initialize application
47performance.mark("app-init-end");
48performance.measure("app-init", "app-init-start", "app-init-end");
49
50const measure = performance.getEntriesByName("app-init")[0];
51console.log("App init:", measure.duration + "ms");
52
53// performance.getEntries() — access all recorded entries
54const allEntries = performance.getEntries();
55const slowResources = allEntries
56 .filter((e) => e.entryType === "resource" && e.duration > 500)
57 .sort((a, b) => b.duration - a.duration);
58
59console.log("Slowest resources:", slowResources);
resource-timing.ts
TypeScript
1// Resource Timing API — detailed resource loading breakdown
2function analyzeResourceTiming() {
3 const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
4
5 const byType = resources.reduce((acc, r) => {
6 const ext = r.name.split(".").pop()?.split("?")[0] || "other";
7 const type = getResourceType(ext);
8 if (!acc[type]) acc[type] = { count: 0, totalSize: 0, totalDuration: 0 };
9 acc[type].count++;
10 acc[type].totalSize += r.transferSize;
11 acc[type].totalDuration += r.duration;
12 return acc;
13 }, {} as Record<string, { count: number; totalSize: number; totalDuration: number }>);
14
15 console.table(
16 Object.entries(byType).map(([type, data]) => ({
17 type,
18 count: data.count,
19 sizeKB: (data.totalSize / 1024).toFixed(1),
20 avgDuration: (data.totalDuration / data.count).toFixed(0) + "ms",
21 }))
22 );
23}
24
25function getResourceType(ext: string): string {
26 const map: Record<string, string> = {
27 js: "script", mjs: "script", css: "style",
28 png: "image", jpg: "image", jpeg: "image",
29 webp: "image", avif: "image", gif: "image", svg: "image",
30 woff2: "font", woff: "font", ttf: "font",
31 json: "fetch", xml: "fetch",
32 };
33 return map[ext] || "other";
34}
35
36// Navigation Timing API — server to browser breakdown
37function analyzeNavigationTiming() {
38 const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming;
39
40 const timing = {
41 redirect: nav.redirectEnd - nav.redirectStart,
42 dns: nav.domainLookupEnd - nav.domainLookupStart,
43 tcp: nav.connectEnd - nav.connectStart,
44 ssl: nav.secureConnectionStart > 0
45 ? nav.connectEnd - nav.secureConnectionStart : 0,
46 ttfb: nav.responseStart - nav.requestStart,
47 contentDownload: nav.responseEnd - nav.responseStart,
48 domInteractive: nav.domInteractive - nav.startTime,
49 domComplete: nav.domComplete - nav.startTime,
50 loadEvent: nav.loadEventEnd - nav.startTime,
51 };
52
53 console.table(timing);
54 return timing;
55}
Performance Measurement Tools

A complete performance toolkit includes lab tools for controlled testing, field tools for real-user monitoring, and browser DevTools for debugging. No single tool gives the full picture.

ToolTypeBest ForKey Features
LighthouseLabAutomated auditing, CI integrationScore, recommendations, performance budget
Chrome DevToolsLabDebugging, profiling, network analysisPerformance panel, flame charts, coverage
WebPageTestLabAdvanced testing, multi-location, videoWaterfall charts, filmstrip, Speed Index
PageSpeed InsightsLab + FieldQuick overview, CrUX dataLighthouse + real-user data combined
CrUX DashboardFieldLong-term trend analysisBigQuery dataset of real-user metrics
react-devtoolsLabReact component profilingRender timing, why-did-you-render
perf-tools.sh
Bash
1# Lighthouse CLI — run performance audit
2npx lighthouse https://example.com \
3 --output=html \
4 --output-path=./lighthouse-report.html \
5 --only-categories=performance \
6 --chrome-flags="--headless"
7
8# Lighthouse CI — automated in CI/CD pipeline
9npm install -g @lhci/cli
10
11# lighthouserc.js
12module.exports = {
13 ci: {
14 collect: {
15 url: ["http://localhost:3000"],
16 numberOfRuns: 3,
17 },
18 assert: {
19 assertions: {
20 "categories:performance": ["error", { minScore: 0.9 }],
21 "first-contentful-paint": ["error", { maxNumericValue: 2000 }],
22 "largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
23 "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
24 "total-blocking-time": ["error", { maxNumericValue: 300 }],
25 },
26 },
27 upload: {
28 target: "lhci",
29 serverBaseUrl: "https://lhci.example.com",
30 },
31 },
32};
33
34# Run Lighthouse CI
35lhci autorun
36
37# WebPageTest API
38curl "https://www.webpagetest.org/runtest.php?url=example.com&k=API_KEY&f=json"
39
40# Performance budget withbundlesize
41npx bundlesize --config .bundlesizerc.json

best practice

Use lab tools (Lighthouse, DevTools) for development and CI, and field tools (CrUX, RUM) for production monitoring. Lab data is consistent and reproducible; field data reflects real user conditions. Never rely on only one type.
Browser Rendering Pipeline

Understanding the browser rendering pipeline is essential for performance optimization. Every pixel on screen goes through a series of steps: JavaScript execution, Style calculation, Layout, Paint, and Composite. Bottlenecks at any step cause jank.

rendering-pipeline.txt
TEXT
1The Browser Rendering Pipeline:
2
3 JavaScript ──► Style ──► Layout ──► Paint ──► Composite
4 Execution Calculation
5
6 1. JavaScript: Execute event handlers, modify DOM
7 2. Style: Calculate which CSS rules apply to each element
8 3. Layout: Compute exact position and size of every element
9 4. Paint: Fill in pixels — backgrounds, text, borders, shadows
10 5. Composite: Layer management and GPU composition
11
12 ── Optimization Tiers ──
13
14 Cheapest: Composite only (transform, opacity)
15 - transform: translateX(100px)
16 - transform: scale(1.5)
17 - opacity: 0.5
18
19 Moderate: Paint only (no layout recalc)
20 - box-shadow
21 - color, background-color
22 - visibility
23 - outline
24
25 Expensive: Layout recalculation
26 - width, height, margin, padding
27 - top, left, right, bottom
28 - border-width
29 - font-size, line-height
30
31 Most Expensive: Layout + Paint + Composite
32 - Adding/removing DOM elements
33 - Changing element display
34 - Resizing the viewport
rendering-optimization.ts
TypeScript
1// Layout thrashing — reading and writing DOM properties alternately
2// This forces the browser to recalculate layout on every read
3function layoutThrashing() {
4 const elements = document.querySelectorAll(".item");
5 elements.forEach((el) => {
6 const height = el.getBoundingClientRect().height; // READ — forces layout
7 el.style.height = height * 2 + "px"; // WRITE — invalidates layout
8 const width = el.getBoundingClientRect().width; // READ — forces layout again!
9 el.style.width = width + 10 + "px"; // WRITE — invalidates layout again!
10 });
11}
12
13// Batch DOM reads and writes to avoid layout thrashing
14function batchedDOMOperations() {
15 const elements = document.querySelectorAll(".item");
16
17 // Phase 1: Read all measurements
18 const measurements: { height: number; width: number }[] = [];
19 elements.forEach((el) => {
20 const rect = el.getBoundingClientRect();
21 measurements.push({ height: rect.height, width: rect.width });
22 });
23
24 // Phase 2: Apply all writes
25 elements.forEach((el, i) => {
26 el.style.height = measurements[i].height * 2 + "px";
27 el.style.width = measurements[i].width + 10 + "px";
28 });
29}
30
31// Use requestAnimationFrame to batch visual updates
32function smoothAnimation() {
33 let pendingUpdate = false;
34
35 function onScroll() {
36 if (!pendingUpdate) {
37 requestAnimationFrame(() => {
38 updateStickyHeader();
39 pendingUpdate = false;
40 });
41 pendingUpdate = true;
42 }
43 }
44
45 window.addEventListener("scroll", onScroll, { passive: true });
46}
47
48// Use IntersectionObserver for off-screen detection
49// (no layout thrashing — observer runs off the main thread)
50const observer = new IntersectionObserver(
51 (entries) => {
52 entries.forEach((entry) => {
53 if (entry.isIntersecting) {
54 loadContent(entry.target);
55 observer.unobserve(entry.target);
56 }
57 });
58 },
59 { rootMargin: "200px" }
60);

warning

Layout thrashing is the most common cause of jank. Never read layout properties (offsetHeight, getBoundingClientRect, getComputedStyle) immediately after writing layout properties. Batch reads first, then batch writes. Use requestAnimationFrame for visual updates.
Performance Budgets

A performance budget sets limits on metrics that affect performance — bundle size, request count, image weight, and Core Web Vitals thresholds. Without budgets, performance degrades incrementally as features are added.

lighthouse-budget.json
JSON
1{
2 "budgets": [
3 {
4 "type": "initial",
5 "maximumWarning": "200kb",
6 "maximumError": "300kb"
7 },
8 {
9 "type": "bundle",
10 "name": "vendor",
11 "maximumWarning": "150kb",
12 "maximumError": "200kb"
13 },
14 {
15 "type": "bundle",
16 "name": "main",
17 "maximumWarning": "100kb",
18 "maximumError": "150kb"
19 }
20 ]
21}
performance-budgets.ts
TypeScript
1// size-limit — lightweight performance budget tool
2// .size-limit.json
3[
4 {
5 "name": "React App",
6 "path": "dist/index.js",
7 "import": "{ App }",
8 "limit": "100 KB",
9 "gzip": true
10 },
11 {
12 "name": "CSS",
13 "path": "dist/styles.css",
14 "limit": "30 KB",
15 "gzip": true
16 },
17 {
18 "name": "Dependencies",
19 "path": "dist/vendor.js",
20 "limit": "200 KB",
21 "gzip": true,
22 "ignore": ["react", "react-dom"]
23 }
24]
25
26// package.json scripts
27{
28 "scripts": {
29 "size": "size-limit",
30 "size:why": "size-limit --why"
31 },
32 "devDependencies": {
33 "size-limit": "^11.0.0",
34 "@size-limit/preset-small-lib": "^11.0.0"
35 }
36}
37
38// bundlesize — CI bundle size checks
39// package.json
40{
41 "bundlesize": [
42 {
43 "path": "./dist/**/*.js",
44 "maxSize": "50 kB",
45 "compression": "gzip"
46 },
47 {
48 "path": "./dist/**/*.css",
49 "maxSize": "20 kB",
50 "compression": "gzip"
51 }
52 ]
53}
54
55// CI integration (GitHub Actions)
56// .github/workflows/budget.yml
57// runs: npx size-limit
58// fails if any budget exceeds the limit
🔥

pro tip

Start with conservative budgets and tighten over time. A common starting point: 170KB gzipped for initial JavaScript, 30KB for CSS, and under 2MB total page weight. Track budgets in CI and block merges when they are exceeded.
Optimization Strategies

Performance optimization is a systematic process. Start by measuring, identify the biggest bottleneck, optimize, then measure again. Here are the highest-impact strategies organized by their target metric.

Improving LCP

1.Optimize server response time (TTFB) — use edge caching, optimize database queries
1.Use a CDN for all static assets and API responses where possible
1.Preload the LCP resource (hero image, critical font)
1.Remove render-blocking resources — inline critical CSS, defer non-critical JS
1.Use responsive images with srcset to serve optimal sizes
1.Compress images — use WebP or AVIF formats
1.Implement server-side rendering (SSR) or static generation (SSG)

Improving INP

2.Break up long tasks (>50ms) using setTimeout or scheduler.yield()
2.Use web workers for heavy computation off the main thread
2.Minimize DOM size — keep under 1,500 elements for smooth interaction
2.Debounce event handlers for rapid-fire events (scroll, resize, input)
2.Use requestIdleCallback for non-urgent work
2.Reduce JavaScript execution time — code-split, tree-shake, lazy-load
2.Avoid forced synchronous layouts — batch DOM reads and writes

Improving CLS

3.Always set width and height on images and videos
3.Use aspect-ratio CSS property for responsive media containers
3.Reserve space for ads and dynamic content with min-height
3.Use CSS containment (contain: layout) for complex components
3.Avoid inserting content above existing content after page load
3.Use font-display: optional for non-critical web fonts
3.Prefer transform animations over layout-triggering properties
Best Practices
Measure performance before and after every optimization — never guess
Use both lab tools (Lighthouse, DevTools) and field tools (CrUX, RUM)
Set performance budgets and enforce them in CI/CD pipelines
Optimize the critical rendering path — minimize render-blocking resources
Use CDNs for all static assets and consider edge computing for dynamic content
Implement caching strategies — browser, server, and CDN layers
Optimize images aggressively — they are usually the largest resources
Minimize third-party scripts — each one adds latency and main thread work
Monitor Core Web Vitals in production using real-user monitoring
Make performance a team responsibility — track it like you track uptime
$Blueprint — Engineering Documentation·Section ID: PERF-01·Revision: 1.0

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.