Web Performance
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 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.
| 1 | // Measuring LCP with Performance Observer |
| 2 | function 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 |
| 25 | function 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.
| 1 | // Measuring INP with Performance Observer |
| 2 | function 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 | |
| 36 | function 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.
| 1 | // Measuring CLS with Performance Observer |
| 2 | function 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 | } |
| Metric | Good | Needs Improvement | Poor | Measures |
|---|---|---|---|---|
| LCP | ≤ 2.5s | 2.5s - 4.0s | > 4.0s | Loading performance |
| INP | ≤ 200ms | 200ms - 500ms | > 500ms | Interactivity |
| CLS | ≤ 0.1 | 0.1 - 0.25 | > 0.25 | Visual stability |
Beyond Core Web Vitals, several other metrics provide insight into specific aspects of performance. These are useful for diagnosing issues and tracking specific optimizations.
| 1 | // Time to First Byte (TTFB) — server response time |
| 2 | // Measures the time from navigation start to first byte received |
| 3 | const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming; |
| 4 | const ttfb = navigation.responseStart - navigation.requestStart; |
| 5 | console.log("TTFB:", ttfb, "ms"); |
| 6 | |
| 7 | // First Contentful Paint (FCP) — when first DOM content renders |
| 8 | const fcp = performance.getEntriesByName("first-contentful-paint")[0]; |
| 9 | console.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 |
| 13 | function 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 |
| 30 | function 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 |
| 45 | function 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
The browser provides a rich set of APIs for measuring performance. Understanding these APIs is essential for building custom performance monitoring and diagnosing issues.
| 1 | // PerformanceObserver — the primary API for performance monitoring |
| 2 | const 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 |
| 39 | observer.observe({ |
| 40 | type: "largest-contentful-paint", |
| 41 | buffered: true, |
| 42 | }); |
| 43 | |
| 44 | // performance.measure() — custom timing measurements |
| 45 | performance.mark("app-init-start"); |
| 46 | // ... initialize application |
| 47 | performance.mark("app-init-end"); |
| 48 | performance.measure("app-init", "app-init-start", "app-init-end"); |
| 49 | |
| 50 | const measure = performance.getEntriesByName("app-init")[0]; |
| 51 | console.log("App init:", measure.duration + "ms"); |
| 52 | |
| 53 | // performance.getEntries() — access all recorded entries |
| 54 | const allEntries = performance.getEntries(); |
| 55 | const slowResources = allEntries |
| 56 | .filter((e) => e.entryType === "resource" && e.duration > 500) |
| 57 | .sort((a, b) => b.duration - a.duration); |
| 58 | |
| 59 | console.log("Slowest resources:", slowResources); |
| 1 | // Resource Timing API — detailed resource loading breakdown |
| 2 | function 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 | |
| 25 | function 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 |
| 37 | function 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 | } |
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.
| Tool | Type | Best For | Key Features |
|---|---|---|---|
| Lighthouse | Lab | Automated auditing, CI integration | Score, recommendations, performance budget |
| Chrome DevTools | Lab | Debugging, profiling, network analysis | Performance panel, flame charts, coverage |
| WebPageTest | Lab | Advanced testing, multi-location, video | Waterfall charts, filmstrip, Speed Index |
| PageSpeed Insights | Lab + Field | Quick overview, CrUX data | Lighthouse + real-user data combined |
| CrUX Dashboard | Field | Long-term trend analysis | BigQuery dataset of real-user metrics |
| react-devtools | Lab | React component profiling | Render timing, why-did-you-render |
| 1 | # Lighthouse CLI — run performance audit |
| 2 | npx 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 |
| 9 | npm install -g @lhci/cli |
| 10 | |
| 11 | # lighthouserc.js |
| 12 | module.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 |
| 35 | lhci autorun |
| 36 | |
| 37 | # WebPageTest API |
| 38 | curl "https://www.webpagetest.org/runtest.php?url=example.com&k=API_KEY&f=json" |
| 39 | |
| 40 | # Performance budget withbundlesize |
| 41 | npx bundlesize --config .bundlesizerc.json |
best practice
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.
| 1 | The 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 |
| 1 | // Layout thrashing — reading and writing DOM properties alternately |
| 2 | // This forces the browser to recalculate layout on every read |
| 3 | function 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 |
| 14 | function 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 |
| 32 | function 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) |
| 50 | const 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
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.
| 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 | } |
| 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
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
Improving INP
Improving CLS
Community
Get help on Slack, Discord or VIP
Stuck on a guide? Join the community and ask.