TL;DR
- Speed: Fast first paint, no layout shifts, instant interactions (aim < 200ms).
- Cut JS: Split code, break long tasks, selective hydration.
- Images & fonts: Modern formats, intrinsic sizes, preload/priority; subset fonts with font-display.
- Network: Preload/preconnect, HTTP/2/3, priority hints, smart caching.
- Render: SSR/streaming, lean critical CSS, avoid layout thrash.
- Third‑parties: Gate behind consent, use lite embeds.
- Offload: Move heavy work to Web Workers/WASM.
- Resilience: Service Worker caching + bfcache correctness.
- Guardrails: CI budgets, automated Lighthouse, real‑user monitoring.
- Iterate: Fix one metric, one asset, one tool—measure and repeat.
Introduction
In modern web development, performance is not an afterthought, a "nice-to-have," or a task to be ticketed for "later." A slow site is a broken site. Period. It's a direct tax on your user experience, a silent killer of conversion rates, and a public penalty on your search rankings. Users today have zero patience for jank, layout shifts, or slow interactions. They don't just expect speed; they demand it. Anything less is a failure of engineering.
This guide is not a list of gentle suggestions. It's a technical, opinionated playbook for engineers, outlining the 2025 standards for web performance. The principles and techniques covered here are not theoretical—they are the exact ones used to build the very site you are reading right now. This page itself is a live case study, and you're encouraged to inspect the results for yourself.

This article is the first part of a larger series, and it's a comprehensive map of the performance landscape. We will systematically cover the Top 20 performance optimizations. We won't just look at what to do, but why it's critical. We'll go from high-level metrics like INP (Interaction to Next Paint) down to the nitty-gritty of JavaScript execution budgets. We'll cover the 'big wins' like image strategy and font loading, the 'silent killers' like third-party scripts, and the 'free' wins you're probably missing, like the bfcache. We'll explore modern framework features for server-side rendering and code splitting, main-thread offloading with Web Workers, and finally, establishing sane build and deploy hygiene. This is the deep dive you've been looking for; let's get to work.
Strategic Focus: Pick the Right North Star
Before you start, define your goal. For marketing sites, a high Lighthouse score is essential for SEO and ranking. For task‑based applications, prioritize real user responsiveness by focusing on INP and TTI.
- Marketing sites: Optimize LCP/CLS/FCP, minimize initial JS, and be ruthless with third‑party scripts to secure a 90+ mobile Lighthouse score.
-
Task‑based apps: Optimize interaction latency—instrument INP, split code, break up long tasks, and defer non‑urgent work so interactions stay under
200ms.
Tip: Let your north star set your budgets. SEO landing pages live and die by Lighthouse; productivity apps live and die by INP and TTI.
Applicability & Tooling
Most guidance in this guide is framework-agnostic and applies to any stack (vanilla HTML/CSS/JS, React, Vue, Angular, etc.). Wherever we reference React/Next.js, it's because those features currently offer strong defaults for performance (e.g., route-level code splitting, Image/Font tooling, Server Components, streaming SSR, selective hydration) that map directly to the goals of smaller JS, faster LCP, and better INP.
If you are not on React/Next.js, look for the equivalent primitives in your ecosystem (e.g., islands in Astro, resumability in Qwik, SSR + lazy hydration in SvelteKit/Nuxt/SolidStart). The principles here—minimize JS, prioritize the LCP image, lazy‑load below the fold, defer third‑party code, offload heavy work—apply universally.
React-specific sections are clearly labeled. Everything else is stack-neutral.
Core Web Vitals & Key Metrics
Before you can optimize, you must measure. Performance isn't about feeling fast; it's about hitting specific, user-centric metrics. These are your non-negotiable targets, as Core Web Vitals directly impact search rankings and user experience. If you aren't measuring, you're just guessing.
Critical Metrics (2025)
This is your dashboard. Your goal is to get all of these into the green, especially on mobile. The new king here is INP, which has replaced FID and is a much more comprehensive measure of user-felt responsiveness.
-
Lighthouse Score:
90+ (mobile) -
First Contentful Paint (FCP):
< 1.5s -
Largest Contentful Paint (LCP):
< 2.5s -
Time to Interactive (TTI):
< 3.5s -
Cumulative Layout Shift (CLS):
< 0.1 -
Interaction to Next Paint (INP):
< 200ms(The new Core Web Vital) -
Total Blocking Time (TBT): Aim for
< 200ms -
Long Tasks: No single task
> 50mson the main thread - Memory: Watch heap growth; no GC thrash after 30s of interaction
-
Network Payload:
< 2 MBtotal
Red Flags (Fix Immediately)
If you see any of these, stop and investigate. These are not subtle optimization points; they are signs of critical problems that are actively costing you users and ranking.
- Device heating up during website usage (a massive CPU/GPU problem)
- Animations are janky or stuttering
- CPU usage spikes
> 20%on mobile devices - A simple component's bundle size is
> 500KB - You are creating new DOM elements in frequent intervals (e.g., on scroll)
- Your mobile Lighthouse score is
< 85
Retired metric: First CPU Idle
First CPU Idle is deprecated in Lighthouse 6+. Prefer Total Blocking Time (TBT) and Time to Interactive (TTI) for interactivity readiness.
Anti‑Pattern: LCP Opacity Hack
Don't try to "game" LCP by rendering the LCP element with near‑zero opacity (e.g., opacity: 0.01) and then switching to opacity: 1. This does not improve real user experience, can be discounted by browsers, and risks accessibility/SEO issues.
- Why it's bad: LCP should reflect visible, meaningful content. Near‑invisible pixels don't help users and can be flagged by anti‑cheating heuristics.
-
Do this instead: Preload the actual LCP image, use
fetchpriority="high", set explicitwidth/height(oraspect-ratio), compress to AVIF/WebP, and avoid layout shifts.
/* ❌ Anti-pattern */
.lcp {
opacity: 0.01; /* looks invisible to users but "counts" — don't do this */
}
/* ✅ Correct approach: make it fast and stable, not invisible */
.lcp {
display: block;
width: 100%;
aspect-ratio: 16/9;
}
Go Deeper: Focus on meaningful LCP improvements: preload the hero image, size it intrinsically, and minimize main‑thread work. Don't attempt metric hacks—they won't help users and may be ignored.
Canvas and LCP: When Exclusion Is Legit
Images drawn into a canvas do not count toward LCP. This can lower your reported LCP, but it does not make your page inherently faster.
- Don't abuse it: Never move your hero/meaningful content into canvas just to dodge LCP—it's deceptive, harms accessibility/SEO, and doesn't improve UX.
-
Legit use cases: Graphics/visualization apps where canvas is the product. Use a small poster
imgfor fast paint, then draw to canvas when ready. -
Better default: Keep primary imagery as
img/pictureand optimize: preload +fetchpriority="high", AVIF/WebP, intrinsic sizes, CDN caching.
<!-- Poster + canvas swap pattern (keep UX first) -->
<figure class="viz">
<img src="/images/chart-poster.avif" alt="Chart placeholder" width="1200" height="675" decoding="async" loading="eager" fetchpriority="high" />
<canvas id="chart" width="1200" height="675" hidden></canvas>
</figure>
<script type="module">
const img = document.querySelector('.viz img')
const canvas = document.querySelector('#chart')
// After drawing completes, swap in canvas
requestAnimationFrame(() => { canvas.hidden = false; img.style.display = 'none' })
</script>
Mobile-First Performance
Stop testing on your 5G-connected, top-of-the-line desktop. The majority of your users are on mobile devices, often on slower networks and with less powerful hardware. You must prioritize mobile performance, not treat it as an afterthought. Mobile devices have thermal limits; if your site makes them heat up, the OS will throttle your CPU, and performance will collapse. Optimize for a low-end Android phone on a 3G connection, and you'll be fast for everyone.
Mobile Testing Requirements
Emulators are not enough. You must test on real hardware to understand the true user experience.
- Test on an actual mobile device, not just a resized desktop browser window.
- Check all performance metrics on a slow 3G connection.
- Test on low-end devices, not just the latest flagship phone.
- Monitor CPU usage and thermal behavior; if the device gets hot, you have a serious problem.
Mobile Animation Strategy
Animations that are smooth on a desktop can be jank-filled disasters on mobile. The main rule: delay animations on mobile until the page is stable and critical resources are loaded.
- Wait for critical resources (images, fonts) to load before starting any animations.
- Apply longer delays on mobile (e.g.,
2s+) versus desktop (immediate). - Use shorter animation durations on mobile (e.g.,
0.3s) for a snappier feel. - Detect mobile devices and disable heavy animations entirely (e.g., complex 3D effects, filters).
Go Deeper: Research how to use your browser's DevTools to throttle your network to "Slow 3G." Then, connect a real Android or iOS device to your computer for remote debugging. This is the only way to see the real-world performance of your site.
Animation Performance
Animations are a primary source of jank and poor perceived performance. A single bad animation can trigger expensive layout recalculations and drain a mobile battery. You must optimize all animations to be cheap, smooth, and respectful of the user's device and preferences.
Animation Performance Rules
Follow these rules religiously to keep animations off the main thread and running smoothly at 60fps.
-
Duration: Keep animations short (
0.3-0.5smax). Long animations feel slow. -
GPU-Accelerated Properties: Only animate
transform,opacity, andscale. These can be handled by the GPU and avoid costly main-thread work. -
Avoid Layout Properties: Never animate properties that trigger layout or paint, such as
width,height,margin,padding, orposition(top/left). Animating these causes expensive browser recalculations for every frame. - Triggers: Use scroll-triggered animations that fire only once. Avoid re-animating on every scroll.
-
Stagger Delays: Keep stagger delays short (
0.1s), avoiding long, drawn-out sequences.
Animation Best Practices
- Use CSS transforms (
translate()) over changingtop/leftpositions. - Use the
will-changeproperty strategically. Don't apply it to every element. - Respect user preferences with the
prefers-reduced-motionmedia query.
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
- Avoid infinite animations unless they are a core part of the user interaction.
- Pause or throttle non-essential animations (like decorative loops) when the tab is hidden using the
visibilitychangeevent. This saves CPU and battery in the background.
GPU Acceleration with will-change
The will-change CSS property is a hint to the browser that an element is about to change. When used correctly, it allows the browser to move the element to its own compositor layer, handing it off to the GPU for optimization. This results in silky-smooth 60fps animations with minimal CPU usage.
How to use:
/* Hinting a transform animation */
.my-animating-element {
will-change: transform;
}
/* Hinting multiple properties */
.my-other-element {
will-change: transform, opacity;
}
Best Practices for will-change:
- Do: Apply it just before an animation starts (e.g., on hover) and remove it when the animation ends. This frees up GPU memory.
- Don't: Overuse it. Each new layer consumes GPU memory (~1-2MB per layer). Applying it to dozens of elements will harm performance, not help it.
- Don't: Apply it to static elements. It's a hint for upcoming changes.
Component-Specific Guidelines
Not all animations are equal. Tune your animations based on the component's function:
-
Sliders/Carousels: Use faster transitions (
~400ms) but longer autoplay delays for readability. -
Forms & Interactive Elements: Animations should be fast and snappy (
~0.3s) with minimal offsets. - Navigation Elements: Transitions should be very fast to avoid delaying the user.
Go Deeper: Research the browser rendering pipeline (Style -> Layout -> Paint -> Composite). Understanding this will make it clear why animating transform is cheap and animating width is expensive. Also, read up on the prefers-reduced-motion media query to make your site accessible.
Image Performance & Optimization
Images are often the single largest asset on a page and the most common cause of a slow LCP (Largest Contentful Paint) and high CLS (Cumulative Layout Shift). You must optimize all images; this is not optional. Every unoptimized image on your site is actively harming your performance metrics and user experience.
Image Loading Strategy
Don't treat all images the same. Their position on the page dictates their loading priority.
- Above-fold Images (Hero): These are critical. They should be preloaded immediately. This is often your LCP element, so it needs the highest priority.
- Below-fold Images: These should be lazy-loaded using native lazy loading to save bandwidth and speed up the initial page load.
- Progressive Loading: Use placeholders like a "blur-up" effect or a traced SVG. This gives a feeling of instant speed, even before the full image has downloaded.
Image Best Practices (2025)
Follow this checklist for every image you serve:
-
Intrinsic Size: Always define
widthandheightattributes (oraspect-ratio) on your image tags. This is the single most important fix for CLS. - Format Priority: Use modern formats. The priority should be AVIF > WebP > JPEG. Use a CDN or build process to automatically serve the best format the user's browser supports.
- The LCP Image: Your LCP image (usually the hero) is special. It must be treated differently.
- All Other Images: All non-LCP images should be lazy-loaded.
-
Responsive Images: Use the
srcsetandsizesattributes to serve different image sizes based on the user's viewport and device pixel ratio (DPR).
<!-- Example: Responsive srcset and sizes -->
<img src="image-small.jpg"
srcset="image-small.jpg 480w,
image-medium.jpg 800w,
image-large.jpg 1200w"
sizes="(max-width: 600px) 480px,
800px"
alt="A responsive image" />
-
Alt Text: Always include descriptive
alttext. This is critical for accessibility and also helps SEO.
CLS Prevention with Skeleton UI
For dynamic content loading (e.g., lists of cards), render a Skeleton UI to reserve space and keep the layout stable while content or images fetch—effectively eliminating CLS.
<!-- Placeholder reserving space for a card while data loads -->
<div class="card skeleton">
<div class="media"></div>
<div class="text-line w-60"></div>
<div class="text-line w-40"></div>
</div>
.card { width: 100%; }
/* Reserve media height deterministically to avoid shift */
.card .media { width: 100%; aspect-ratio: 16/9; border-radius: 8px; }
/* Simple shimmer */
.skeleton .media, .skeleton .text-line {
background: linear-gradient(90deg, #eee 25%, #f5f5f5 37%, #eee 63%);
background-size: 400% 100%;
animation: shimmer 1.2s infinite linear;
border-radius: 6px;
}
.skeleton .text-line { height: 12px; margin-top: 8px; }
.skeleton .w-60 { width: 60%; }
.skeleton .w-40 { width: 40%; }
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
Key: reserve dimensions via width/height or aspect-ratio; swap the skeleton with real content once loaded to maintain a zero-shift layout.
Code Splitting & JS Bundle Size
Your JavaScript bundle is the single greatest threat to your site's performance. A large bundle blocks the main thread, delays interactivity, and costs your users real money in data charges. You must minimize your bundle size. The goal is to send only the absolute minimum code required for the user's initial view, and load the rest on demand.
Code Splitting Rules
Code splitting is the practice of breaking your large bundle into smaller, logical chunks that can be loaded as needed.
- Use dynamic imports (e.g.,
React.lazy()) for heavy components like modals, charts, or complex UI elements that aren't needed immediately. - Split by route: Your bundler (like in Next.js) should automatically do this. Users should only download the code for the page they are currently on.
- Lazy load third-party libraries: Don't import a 500KB library on initial load if it's only used for one specific feature. Import it dynamically when the user interacts with that feature.
- Avoid importing entire libraries; import specific functions only (e.g.,
import { debounce } from 'lodash-es', notimport _ from 'lodash').
A critical technique in frameworks like Next.js is using ssr: false on dynamic imports for client-only components. This prevents the component from being included in the server-side render and the initial client-side bundle, saving valuable parsing time.
// Example: Dynamically importing a heavy, client-only component
import dynamic from 'next/dynamic'
const Heavy3DModel = dynamic(() => import('../components/Heavy3DModel'), {
ssr: false,
loading: () => <p>Loading model...</p>
})
Bundle Size Limits (2025 Targets)
These are aggressive but necessary for fast mobile performance.
-
Initial JS (gzipped):
≤ 170-200KB. This is the new baseline for a "fast" mobile experience. This decompresses to ~500-600KB of parsed JS, which is already a heavy load for a mid-range phone. -
Total Initial Bundle: Aim for
< 200KBgzipped. -
Simple Components: A simple component's code should not be
> 500KB(a red flag).
Heavy/Lazy Component Strategy
- Use
<Suspense>to provide a clean loading fallback for your lazy-loaded components. - Detect device capabilities. If the user is on a low-end device, provide a fallback or don't load the heavy feature at all.
- Make resource-intensive features opt-in. Don't auto-play a 3D animation; let the user click "play."
-
Defer non-critical operations like analytics or console logging. Use
requestIdleCallbackto run these tasks when the main thread is free. - Audit your MutationObservers and IntersectionObservers. Disable heavy DOM scraping or observers in production unless absolutely necessary, and always disconnect them on unmount.
Go Deeper: Install and run @next/bundle-analyzer or webpack-bundle-analyzer on your production build. This will give you a visual "treemap" of your bundle. You will be shocked at what you find. This is the first step to identifying and removing unnecessary code.
CSS Performance
CSS is a render-blocking resource, meaning the browser won't paint the page until it has downloaded and parsed your CSS. Poorly written or organized CSS can be a significant performance bottleneck, causing jank, layout thrashing, and a slow FCP (First Contentful Paint).
CSS Performance Rules
Keep your CSS lean and efficient by following these rules:
-
Nesting Depth: Avoid deep nesting (
>3 levels). Deeply nested selectors (e.g.,.nav > .list > .item > a) are computationally expensive for the browser to match. -
Selector Simplicity: Keep selectors simple and specific. Class-based selectors (
.my-component) are far more performant than complex type or attribute selectors. -
Animations: As covered in the animation section, only animate
transform,opacity, andscale. Never animate layout properties. - CSS Variables: Use CSS variables for theming; they are highly performant and efficient.
CSS Best Practices (2025)
Modern CSS offers powerful tools to optimize rendering. You must use them.
- Critical CSS: Inline the bare minimum CSS required to style the above-the-fold content. Load the rest of your stylesheet asynchronously. This dramatically speeds up FCP.
- Zero-Runtime CSS: Prefer CSS solutions that do their work at build time (like vanilla-extract, compiled CSS, or Linaria). If you must use runtime CSS-in-JS, ensure your server-side rendering is configured correctly to avoid costly hydration.
-
content-visibility: auto: Use this property on off-screen sections of your page. It tells the browser to skip all rendering work (style, layout, and paint) for that section until it's about to scroll into view.
CSS Containment
This is one of the most powerful and underused CSS properties for performance. The contain property allows you to isolate a part of the DOM, telling the browser that its contents are independent of the rest of the page.
/* Tell the browser to isolate layout, style, and paint calculations */
.isolated-component {
contain: layout style paint;
}
Benefits of CSS Containment:
-
Prevents Layout Thrashing: If you have an animated element inside a
containblock, it won't cause the entire page to reflow. - Reduces Main-Thread Work: The browser can optimize rendering by knowing it doesn't need to recalculate the entire page for a change inside this box.
- When to use it: Use it on complex components like animated sections, carousels, cards with hover effects, or any component that you know will have self-contained animations or style changes.
Go Deeper: Research "Critical CSS" generation tools that can automate this process in your build. Also, investigate the content-visibility property and the contain property. These are the new frontiers of CSS performance.
Resource Loading & Fonts
An effective resource loading strategy is about sequencing. It's not just about loading assets fast, but loading them in the right order. The browser's default behavior is often not optimal. You must take control to prioritize what the user needs to see first.
Resource Loading Rules
- Wait for critical resources: Never start animations before your critical fonts and images are loaded. This prevents jank and ensures your animations are smooth.
- Preload critical images: As mentioned in the image section, preload your LCP image.
-
Load third-party scripts asynchronously: Use the
asyncordeferattributes. A third-party script should never block your page's main content from rendering. - Use Resource Hints: Give the browser a heads-up about external domains.
<!-- Connect to critical domains early -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://www.google-analytics.com">
<!-- Look up DNS for less critical domains -->
<link rel="dns-prefetch" href="https://some-other-third-party.com">
Font Loading Strategy (2025)
Fonts are a notorious source of performance issues, causing CLS (Cumulative Layout Shift) and FOUC (Flash of Unstyled Text). You must optimize font loading.
- Host fonts locally: Stop relying on external font CDNs. Hosting fonts on your own domain eliminates an extra DNS lookup and gives you full control over caching.
- Limit font weights: Do not load all 9 weights of a font (300-900). If your design only uses 400, 500, and 700, only load those. Loading all weights can add 500-800ms of main-thread work.
-
Use
font-display: optional: This is the best choice for performance. It tells the browser to use a fallback font if the web font isn't cached or downloaded immediately. This prevents CLS.font-display: swapis an alternative, but it causes CLS when the font swaps. - Use Variable Fonts: If you need many weights, a single variable font file is often smaller than loading 5-6 individual font files.
- Subset fonts: Only include the characters you actually need (e.g., Latin-only).
-
Preload critical fonts: If you know a font is needed for above-the-fold text, preload it in your
<head>.
/* Example: Self-hosted font with font-display: optional */
@font-face {
font-family: 'MyCustomFont';
src: url('/fonts/my-custom-font.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: optional;
}
Network & Protocol Optimization (2025)
- Compression: Use Brotli compression for all text-based assets (HTML, CSS, JS).
- HTTP/3 (QUIC): If your host supports it, enable HTTP/3 for better performance on spotty mobile networks.
- Speculation Rules API: This is the modern replacement for prefetch/prerender. It allows you to tell the browser which pages a user is likely to visit next, so it can start fetching them in the background.
-
Cache Policies: Use
Cache-Control,ETag, andstale-while-revalidateto allow the browser to serve stale content while fetching an update in the background. Hashed assets should be marked asimmutable.
Go Deeper: Research the Speculation Rules API, as it's the new standard for pre-rendering next-page navigations. Also, deeply investigate your font loading. Use font-display: optional and font subsetting to eliminate layout shift.
Network & Priority Tuning
Use browser and protocol‑level priority signals to get critical bytes first.
Priority Hints (fetchpriority)
Elevate true LCP resources; lower everything else.
<!-- LCP image: highest priority -->
<img src="/images/hero.avif" alt="Hero" width="1600" height="900" loading="eager" fetchpriority="high" />
<!-- Preload hero when using CSS background or responsive pipelines -->
<link rel="preload" as="image" href="/images/hero.avif" fetchpriority="high" />
<!-- Below-the-fold images: keep default/low -->
<img src="/images/gallery-5.webp" alt="" width="800" height="600" loading="lazy" fetchpriority="low" />
Client Hints (DPR, Width, Viewport-Width)
Serve right‑sized images per device; vary on hints.
# Response headers from your origin/CDN
Accept-CH: DPR, Width, Viewport-Width
Vary: DPR, Width, Viewport-Width
Cache-Control: public, max-age=31536000, immutable
// Example server pseudocode
const { dpr = 1, width = 800 } = getClientHints(req)
const targetWidth = Math.min(1600, Math.max(400, Number(width)))
const format = supportsAVIF(req) ? 'avif' : 'webp'
return imageCDN.fetch(`/img/hero_${targetWidth}@${dpr}x.${format}`)
HTTP Priority (RFC 9218)
Set request urgency at the protocol level (HTTP/2/3). Mark LCP assets urgent; mark incremental/lazy assets as low.
# Response headers
Priority: u=1
# Lower priority, incremental (e.g., long list images)
Priority: u=5, i
Check your CDN/framework support (e.g., Cloudflare/fastly/Next.js) to map routes or file types to urgency.
Resource Scheduling & Preconnect Tuning
- Preconnect early to critical third‑party origins you must hit.
- dns-prefetch for less‑critical origins to keep connection setup cheap.
- modulepreload for known‑ahead JS chunks to avoid waterfall.
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://analytics.example.com" />
<link rel="modulepreload" href="/_next/static/chunks/app-abc123.js" />
Tip: Use priority hints sparingly—reserve fetchpriority="high" for the LCP resource. Verify improvements via the Network panel (Initial Priority/Protocol) and RUM.
Component Performance
Performance is not just a high-level concern; it must be applied at the lowest level. Every component you build is a potential performance bottleneck. A single poorly optimized component, repeated in a list, can bring your entire application to a halt. Every component must follow these rules.
Component Checklist
Use this checklist for every component you ship:
- Are images preloaded if above the fold?
- Do animations only start after critical resources are ready?
- Are mobile-specific animation delays applied?
- Are there any infinite animations without user interaction?
- Are there any CPU-intensive filters (like
blur) on mobile? - Has this been tested on an actual low-end mobile device?
- Are there any console errors or warnings?
- Does this component have a Lighthouse score
> 85on mobile (if testable in isolation)?
Component Best Practices
-
Use Semantic HTML: Choose semantic elements such as
button,nav,header, andmaininstead of genericdivwrappers. Semantic HTML improves accessibility, SEO, and browser rendering performance. -
Proper Heading Hierarchy: Structure your content using heading elements from
h1toh6in logical order. Never use headings purely for styling—maintain a clear document outline that reflects your content structure. - Avoid Creating DOM Elements in Frequent Intervals: Generating new DOM nodes on scroll or mouse move events creates severe performance bottlenecks. Implement element recycling patterns or use virtualization libraries for long lists.
-
Optimize Re-renders: In React, use
React.memo,useCallback, anduseMemostrategically. Always profile your components first to identify the root cause of unnecessary re-renders before applying memoization.
// Example: Using React.memo to prevent re-renders
import React from 'react';
const MyComponent = ({ complexProp }) => {
// This component only re-renders when 'complexProp' changes
return <div>{complexProp.value}</div>;
};
// Export the memoized version
export const MemoizedComponent = React.memo(MyComponent);
- Minimize Component Complexity: Design components with a single, focused responsibility. Components that handle multiple concerns become difficult to optimize, test, and maintain over time.
Pre-Deploy Performance Checklist
This is your final pre-deploy gate. Do not ship code to production until you can check these boxes. A single unchecked box can undo all your hard optimization work.
Before Deploying, Verify:
Lighthouse score> 90 (mobile)
LCP < 2.5s
FCP < 1.5s
CLS < 0.1
TTI < 3.5s
Bundle size < 500KB (and ideally < 200KB)
All above-fold images are preloaded
All below-fold images are lazy loaded
Animations are delayed on mobile
No CPU-intensive operations on mobile
Tested on an actual low-end mobile device
Tested on a slow 3G network
No console errors or warnings
Resource hints (preconnect, dns-prefetch) are added for external domains
Go Deeper: This checklist isn't just a suggestion; it should be your CI/CD gate. Research how to integrate Lighthouse CI into your deployment pipeline. You can configure it to automatically fail any build that causes a performance regression, making high performance the default, not an exception.
Common Performance Mistakes
You can spend months optimizing, but a few common mistakes can erase all your progress. These are the "performance killers" – the anti-patterns you must avoid at all costs. An audit for these mistakes should be your first step in any performance refactor.
Performance Killers
×Running heavy animations while critical resources (images, fonts) are still downloading ×Creating new DOM elements in frequent intervals, such as on a scroll or mouse-move event ×Using complex filters (likeblur or drop-shadow) on large elements or on mobile
×Writing long animation durations (>0.5s) that make the UI feel sluggish
×Running animations on mobile without a significant delay (let the page settle first!)
×Not preloading critical LCP images
×Allowing animations to re-trigger on every scroll
×Animating entire sections instead of their individual child items
×Forgetting to respect prefers-reduced-motion
×Animating layout properties (width, height, margin, top, left). This is the cardinal sin of web animation
×Loading heavy, non-critical libraries in your initial bundle
×Not code-splitting your routes
×Leaving console.log statements in production; defer them with requestIdleCallback
×Forgetting to add contain: layout to animated sections, causing full-page layout thrashing
×Loading all font weights (e.g., 300-900) when you only need a few
×Using ssr: true (the default) for heavy, client-only components that don't need to be server-rendered
×Relying on Next.js prefetch when your CDN HTML is stale, causing repeated 404s for old chunk URLs
×Dynamically injecting new content above existing content after the page has settled without a user action (e.g., banners, consent bars). Reserve space upfront or insert below; only place above on explicit user action to prevent CLS
Mobile-Specific Performance Killers
×Not testing on an actual mobile device. This is the #1 mistake. Emulators lie ×Assuming your desktop performance applies to mobile ×Forgetting that mobile devices have thermal limits and will throttle your CPU ×Using heavy background animations or complex 3D effects without device detection Go Deeper: Pick one of these mistakes you know you've made. Go back to an old project and fix it. Then, install an ESLint plugin for performance (like eslint-plugin-jsx-a11y for accessibility) to catch these issues automatically in your code editor before they ever reach production.Testing & Monitoring
Performance optimization is not a one-time task; it's a continuous process. You must have a robust strategy for **testing before you deploy** and **monitoring your metrics in production**. Real-world user performance ( **field data** ) is often very different from your local tests ( **lab data** ).
Testing Tools
You must be proficient with these tools:
- **Lighthouse** : Built into DevTools. Your first-line defense for lab data.
- **PageSpeed Insights** : See both lab data and real-world field data from CrUX.
- **WebPageTest** : The gold standard for deep, granular performance analysis.
- **Performance Tab** : In-browser DevTools. Essential for profiling, finding long tasks, and seeing exactly what the main thread is doing.
- **Bundle Analyzers** : `source-map-explorer` or `webpack-bundle-analyzer` to visually inspect your JS bundles.
Testing Checklist
Your manual testing process must include:
Testing on actual mobile devices (not just emulators)
Testing on slow network connections (throttle to 3G)
Monitoring CPU usage and thermal behavior
Checking for memory leaks and measuring INP (Interaction to Next Paint)
Monitoring & CI Gates (2025)
This is how you prevent regressions and capture **field data**.
- **Performance Budgets in CI** : Set up Lighthouse CI or a similar tool to *fail the build* if a new PR causes a performance regression.
- **RUM (Real User Monitoring)**: Collect Core Web Vitals from your actual users in the field.
- **Long Task API** : Use a
PerformanceObserverin production to sample and report long tasks (> 50ms) and high INP values.
// Example 1: Capture Long Tasks (TBT/INP)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.log('Long Task detected:', entry.duration, 'ms', entry);
// Send data to analytics service
}
}
});
observer.observe({ type: 'longtask', buffered: true });
// Example 2: RUM - Capture Web Vitals in Production (using web-vitals lib)
import { onLCP, onCLS, onINP } from 'web-vitals'
function report(metric) {
fetch('/api/vitals', {
method: 'POST',
keepalive: true, // ensures post works on page unload
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: metric.name, value: metric.value, id: metric.id })
}).catch(() => {})
}
onLCP(report)
onCLS(report)
onINP(report)
Go Deeper: Stop relying only on Lighthouse ("lab data"). Research how to implement Real User Monitoring (RUM) using a service like Vercel Analytics, Sentry, or by manually using the web-vitals library to send "field data" to your own analytics. Field data is the ground truth.
React 18/19 Platform Features
If you're using React, you can't just write useState and useEffect and call it a day. Modern React (18+) has fundamentally changed. It's no longer just a UI library; it's a platform with powerful, built-in features for solving the very performance problems we've discussed. You must leverage these features.
Server Components (RSC)
This is the biggest shift in React's history. The goal: Push as much logic as possible to the server and send a minimal, interactive shell to the client. RSCs run only on the server, have no client-side JS footprint, and are perfect for data fetching and non-interactive content. This isn't just a new component type; it's a new architecture that moves the default from the client to the server, massively reducing your client-side bundle and TBT.
Streaming SSR + Suspense
Stop waiting for the entire page to render on the server. With Streaming SSR, React sends the HTML in chunks. You can wrap slower components (like a data-heavy widget) in <Suspense fallback={<Spinner />}>. The browser will get the main page HTML instantly, show the loading fallback, and then the rest of the HTML "streams" in as it becomes ready, improving your FCP and LCP.
Selective Hydration / Partial Hydration
This works with Streaming SSR. Instead of hydrating the entire page at once (which blocks the main thread), React can now hydrate components selectively. If a user clicks on a component (like a header) while another, heavier component (like a comments section) is still hydrating, React will prioritize hydrating the component the user is interacting with. This is a massive win for your INP score, as it makes the site feel interactive almost immediately.
React Hooks for Performance
-
useTransition: A game-changer for INP. It allows you to mark certain updates as "non-urgent." For example, as a user types in a search box, the input update is marked as "urgent" while the data grid re-rendering below is marked as "non-urgent." This keeps the UI snappy and responsive during complex updates.
// Example: Using useTransition to keep UI responsive
const [isPending, startTransition] = useTransition();
const [inputValue, setInputValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const handleChange = (e) => {
// Urgent: Update the input field immediately
setInputValue(e.target.value);
// Non-urgent: Defer the expensive search query update
startTransition(() => {
setSearchQuery(e.target.value);
});
};
return (
<div>
<input onChange={handleChange} value={inputValue} /> {isPending ? 'Loading results...' : <Results query={searchQuery} />} </div>
);
-
useDeferredValue: Similar touseTransition, this lets you defer re-rendering a non-urgent part of the UI, preventing it from blocking more important work. -
React.memo,useCallback,useMemo: These are your tools for stabilizing renders and preventing unnecessary re-renders. Use them, but use them wisely. Profile first; don't memoize everything.
Virtualization
If you are rendering a list of hundreds or thousands of items, you must use virtualization. Libraries like react-window or react-virtualized avoid creating thousands of DOM nodes by only rendering the items currently visible in the viewport. This is non-negotiable for large data sets and is the difference between a fast UI and a crashing tab.
Go Deeper: If you use React, your #1 priority is to deeply understand React Server Components (RSC) and the new App Router in Next.js. This architecture is the future of the framework and is purpose-built to solve performance at scale.
Data Fetching & Caching
A fast-loading site can be brought to its knees by slow data fetching. Optimizing your bundle is only half the battle; you must also optimize how you fetch, cache, and display data. Every network request is a potential bottleneck.
HTTP Caching Strategy
Don't re-fetch what you don't have to. A well-configured cache is the fastest network request: no network request at all. You must use these headers correctly:
-
Cache-Control: The primary header. Useimmutablefor hashed assets, andstale-while-revalidatefor everything else. -
ETag: Used for cache validation, so the server can send a304 Not Modifiedif the content hasn't changed. -
stale-while-revalidate: The best of both worlds. This directive tells the browser to serve the stale, cached version immediately (for instant speed) and then re-fetch a fresh version in the background.
Edge Cache Colocation
Your data should be as close to your users as your code. Instead of every user hitting your origin server in one location, use a CDN (Content Delivery Network) or edge runtime to render and cache data near your users. This dramatically reduces latency.
SWR Pattern (Stale-While-Revalidate)
This is a UI pattern, not just a cache header. When a component mounts, it should immediately show the cached (stale) data, then trigger a re-validation (a fetch) in the background. Once the fresh data arrives, the component updates. This makes your application feel incredibly fast and responsive, even with changing data.
Storage Optimization
Avoid blocking localStorage reads at init! Reading from localStorage is a synchronous, blocking operation on the main thread. If you do this at the top level of your app to get a user token or theme preference, you are blocking the entire render. Prefer asynchronous storage or use requestIdleCallback for non-critical storage reads.
Go Deeper: Research the stale-while-revalidate (SWR) pattern. Libraries like SWR and React Query implement this out of the box and are essential tools for modern data-driven applications. Also, audit your app for any localStorage.getItem() calls in your initial render path.
Service Workers & Caching Strategies
Service Workers (SW) are essential for **runtime performance** and **resilience**. Pair smart SW strategies with proper HTTP/CDN caching to deliver fast, reliable experiences.
Stale‑While‑Revalidate at Runtime (SWR)
Serve assets fast from cache when available (stale data), then refresh in the background (revalidate). This provides an excellent balance of speed and freshness.
// sw.js (SWR Core Logic)
const RUNTIME_CACHE = 'runtime-v1'
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
event.respondWith((async () => {
const cache = await caches.open(RUNTIME_CACHE)
const cached = await cache.match(event.request)
// Fetch and update cache in background
const networkPromise = fetch(event.request).then((resp) => {
if (resp.status === 200) cache.put(event.request, resp.clone())
return resp
}).catch(() => cached) // Offline fallback to cache
// Return cached immediately if found, else wait for network
return cached || networkPromise
})())
})
Cache Versioning & Workbox Setup
Use Workbox to declare caching strategies, and ensure old cache versions are deleted during activation.
// sw.js (Workbox & Activation Cleanup)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.6.0/workbox-sw.js')
const ALLOWED_CACHES = ['static-v2', 'runtime-v1']
// Workbox: Static assets use Cache-First (fast for immutable files)
workbox.routing.registerRoute(
({ request }) => ['style', 'script', 'worker'].includes(request.destination),
new workbox.strategies.CacheFirst({ cacheName: 'static-v2' })
)
// Activation: Clean up old caches and claim control
self.addEventListener('activate', (event) => {
event.waitUntil(caches.keys().then(keys =>
Promise.all(keys.filter(k => !ALLOWED_CACHES.includes(k)).map(k => caches.delete(k)))
))
self.clients.claim() // control pages right away
self.skipWaiting() // activate new SW immediately
})
SW Cache vs CDN Cache
- **HTML should stay fresh** : Set **`Cache-Control: no-cache`** at CDN; use *network-first* strategy in SW for documents.
- **Hashed assets are immutable** : Set **`Cache-Control: public, max-age=31536000, immutable`** at CDN; use *cache-first* in SW.
- **Purge on deploy** : Invalidate CDN HTML on release so new HTML points to new hashed assets; SW will fetch fresh HTML and update.
Tip: Treat the SW as an edge within the browser. Align its strategies with your CDN: network-first for freshness, cache-first for immutable assets, and SWR where appropriate.
JavaScript Execution Budget
This is a critical, high-level concept. Stop thinking about "making JS faster." Start thinking of it as a strict budget. For a low-end mobile device, your budget for all JavaScript (parsing, compiling, and executing) is extremely small. Once you're over budget, your app is slow. Period.
Execution Budget Rules
-
Hard Budget: Your initial JS load should be
≤ 170-200KBgzipped. This is the aggressive but necessary target for a fast mobile experience. This decompresses to ~500-600KB of parsed JS, which is already a heavy load for a mid-range phone. -
Defer Everything: Use
type="module"anddeferon all your scripts. Never use a blocking script in your<head>unless it's absolutely critical. -
Tree-shaking: Ensure your build is correctly tree-shaking dead code. Use
"sideEffects": falsein yourpackage.jsonwhere appropriate.
Dependency Optimization
Your dependencies are your biggest liability. Audit them relentlessly.
-
Kill Heavy Deps: Find and replace.
moment.js(200KB+) →date-fnsorluxon(20KB).lodash(70KB) →lodash-esfor per-method imports or just use native JS functions. -
Strip Dev Noise: Use a babel plugin (like
babel-plugin-transform-remove-console) to strip allconsole.logand debug messages from your production build.
Dependency Audit Example
Run a focused audit to cut dead weight fast:
-
Analyze: Build with
webpack-bundle-analyzer(or@next/bundle-analyzer) and inspect the treemap for oversized, monolithic libraries. -
Replace: Swap heavy deps with modern, tree-shakeable alternatives (e.g.,
moment.js→date-fnsorluxon). - Measure: Rebuild and re-check the treemap; verify gzipped size and long-task reductions.
// Before: moment (large, non-tree-shakeable)
import moment from 'moment'
const formatted = moment(date).format('YYYY-MM-DD')
// After: date-fns (small, per-function imports)
import { format } from 'date-fns'
const formatted = format(date, 'yyyy-MM-dd')
Tip: Prefer ES module builds and per-method imports (lodash-es) to enable effective tree-shaking.
Code Splitting Discipline
We've mentioned this before, but it's central to your budget. Do not load one giant app.js file. Your code should be split by routes and by user interaction. If a user never clicks the "Profile" button, they should never download the code for the profile page.
Go Deeper: Use source-map-explorer or webpack-bundle-analyzer to create a visual treemap of your production bundle. You will find libraries you didn't even know you were using. This is the single most effective tool for auditing and enforcing your JS budget.
Third-Party Discipline
You can do everything right, only to have your performance destroyed by a single, unoptimized third-party script. Analytics, ad trackers, customer support widgets, and social embeds are the silent killers of performance. You must treat all third-party code as hostile and enforce strict discipline.
Consent-Gated Loading
If a script isn't essential for the initial render, don't load it until you have the user's consent (or a user interaction). Analytics, heatmaps, and chat widgets should not be loaded until after the user has interacted with a consent banner or another part of the page. No consent = no script.
Tag Manager Discipline
If you use a tag manager (e.g., Google Tag Manager), configure strict triggers so non-critical tags fire only on the pages and events where they are required—not globally.
- Default deny: Disable non-essential tags by default; enable them with narrow, page-scoped triggers.
-
Page-scoped triggers: Target by Page Path/URL (e.g.,
^/checkout) ordataLayercontext (page_category). - Consent gates: Require a consent signal before any marketing/analytics tags fire.
-
Event-driven: Prefer custom events (
video:play,form:submit) over broad All Pages triggers.
// dataLayer: scope and consent gates
window.dataLayer = window.dataLayer || []
dataLayer.push({
event: 'page:view',
page_path: location.pathname,
page_category: 'checkout',
consent: { marketing: false }
})
// After user consents (e.g., on checkout only):
dataLayer.push({ event: 'consent:update', consent: { marketing: true } })
In GTM: create triggers such as Page Path matches RegEx ^/checkout and Custom Event consent:update with a marketing-consented condition; bind them only to the tags they unlock.
Sandboxed Embeds
Embeds like YouTube videos or Twitter posts can be disastrous, pulling in megabytes of their own code. Don't embed them directly.
- Lite Embeds: Use a "lite" embed pattern. Show a screenshot of the video with a "play" button. Only when the user clicks the play button do you dynamically load the real YouTube iframe. This saves megabytes on initial load.
-
loading="lazy"on iframes: All iframes must haveloading="lazy"to prevent them from loading until they are near the viewport. -
Sandboxed iframes: Use the
sandboxattribute on iframes to limit their capabilities and prevent them from running malicious code.
Observer Management
Many third-party scripts inject their own MutationObservers or IntersectionObservers to watch your DOM. These can be expensive. Audit your page to see what scripts are observing, and be ruthless about removing any that aren't critical. Always disconnect your own observers on unmount to prevent memory leaks.
Go Deeper: Research the "lite embed" pattern for YouTube and Vimeo. For scripts you must include, use your browser's Performance tab to see how much CPU time they're consuming. Consider loading non-essential scripts on a setTimeout or requestIdleCallback to delay their execution until after your page is interactive.
Main-Thread Offloading
The main browser thread is for UI. It's responsible for rendering, layout, and responding to user input. Any time you run heavy JavaScript on it, you are blocking the UI, causing jank, and destroying your INP score. You must offload heavy work to keep the main thread responsive.
Web Workers
This is your primary tool. A Web Worker runs JavaScript on a completely separate thread. You can send it a heavy task (like parsing a massive JSON file, performing complex data transformations, or image processing) and it will do the work in the background, sending you a message when it's done—all without blocking the main thread for a single millisecond.
OffscreenCanvas
If you have complex rendering tasks, like for charts or filters, you can use an OffscreenCanvas. This allows you to run canvas rendering operations within a Web Worker, again, completely off the main thread.
Timing APIs
Not all work needs a separate thread, sometimes it just needs to be smarter about when it runs.
-
requestIdleCallback: This is for non-critical initialization or analytics. It queues your function to run only when the main thread is idle. This is the perfect way to run "low priority" tasks without interfering with the user experience.
// Example: Using requestIdleCallback for low-priority work
const tasks = [() => console.log('Task 1'), () => console.log('Task 2')];
const runLowPriorityWork = (deadline) => {
// 'deadline.timeRemaining()' shows how many ms we have
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
// perform one analytics task
tasks.shift()();
}
// If there are still tasks, queue them for the next idle period
if (tasks.length > 0) {
requestIdleCallback(runLowPriorityWork);
}
};
// Start the low-priority work when the browser is idle
requestIdleCallback(runLowPriorityWork);
-
requestAnimationFrame: Use this for any visual work (like animations) that must run on the main thread. It ensures your code runs at the optimal time, right before the browser repaints the screen.
WebAssembly (WASM) Performance Discipline
WASM can unlock near‑native performance, but only if you load and execute it without blocking the UI.
Streaming Compilation
Compile while downloading to cut startup latency; fall back when unsupported.
const imports = {}
const url = '/wasm/app.wasm'
let instance
if ('instantiateStreaming' in WebAssembly) {
({ instance } = await WebAssembly.instantiateStreaming(fetch(url), imports))
} else {
const bytes = await (await fetch(url)).arrayBuffer()
({ instance } = await WebAssembly.instantiate(bytes, imports))
}
// Use exports without blocking long on startup
const { compute } = instance.exports
Avoid Main‑Thread Blocking
Initialize and execute heavy WASM work inside a Worker; post results back.
// wasm-worker.js
self.onmessage = async (e) => {
const imports = {}
const url = '/wasm/app.wasm'
let instance
if ('instantiateStreaming' in WebAssembly) {
({ instance } = await WebAssembly.instantiateStreaming(fetch(url), imports))
} else {
const bytes = await (await fetch(url)).arrayBuffer()
({ instance } = await WebAssembly.instantiate(bytes, imports))
}
const result = instance.exports.compute(e.data)
self.postMessage(result)
}
// main thread
const worker = new Worker('/wasm-worker.js', { type: 'module' })
worker.postMessage(inputData)
worker.onmessage = ({ data }) => render(data)
Lazy‑Load Large WASM Bundles
Defer loading until needed; wrap init in a dynamic import.
// load-wasm.js
export async function loadWasm() {
const mod = await import('/wasm/init.js')
return await mod.default()
}
// /wasm/init.js
export default async function init() {
const res = await fetch('/wasm/app.wasm')
const bytes = await res.arrayBuffer()
const { instance } = await WebAssembly.instantiate(bytes, {})
return instance
}
Tips: Serve with Content-Type: application/wasm; feature‑slice modules to keep payloads small; memoize initialized instances; use cross‑origin isolation (COOP/COEP) for threads/SharedArrayBuffer; prefer Workers to keep INP low.
Back/Forward Cache (bfcache)
This is the ultimate performance win, and it's one you get almost for free if you don't make one critical mistake. The bfcache is a browser feature that "freezes" a complete snapshot of your page in memory when you navigate away. If a user clicks the "back" button, the browser doesn't re-download or re-execute anything; it just "un-freezes" the page. The result is an instant page load.
How to Make Pages bfcache-Friendly
There is one primary rule: Do not use unload event listeners.
// ❌ This single line of code will disable the bfcache.
window.addEventListener('unload', () => {
// Sending analytics, cleaning up state, etc.
});
The unload event is old, unreliable, and it breaks bfcache. Any page with an active unload listener will be ineligible for this instant-back feature.
The Modern Replacements
Use modern page lifecycle events instead:
-
pagehide: This event fires when the page is being hidden, including when it's being put into the bfcache. This is the correct, modern replacement forunload. -
visibilitychange: This event is more general and fires whenever the tab's visibility changes (e.g., user switches tabs). It's useful for pausing animations or throttling work when the user isn't looking.
Also, avoid using beforeunload except when absolutely necessary (e.g., to warn a user they have unsaved work).
Go Deeper: Audit your entire codebase and the code of your third-party scripts for unload event listeners. This is the #1 reason sites are not bfcache-friendly. Remove them and replace them with pagehide. You can check if your page is bfcache-eligible in Chrome DevTools (Application > Back/forward cache).
Build/Deploy Hygiene
Finally, your performance efforts can be undermined by a sloppy build or deployment process. "Build/Deploy Hygiene" refers to the set of practices that ensure your production environment is as optimized as your code. Don't ship development code to production.
Production Build Verification
-
NODE_ENV=production: Ensure your build is running with this environment variable. This is the #1 switch that enables optimizations, dead code elimination, and minification in React and other libraries. - Dead Code Elimination: Verify that your tree-shaking is working and unused code is being dropped.
- No Dev Code: Double-check that no development tools or large, dev-only libraries are making it into your production bundle.
Asset Management
-
Immutable Asset URLs: Your bundled assets (JS, CSS) should have content-based hashes in their filenames (e.g.,
main.a8d4c9.js). This allows you to set aggressive, long-term cache TTLs (Time to Live) on them. -
Cache TTLs: Set long cache TTLs for hashed, immutable assets. Set short TTLs (or
no-cache) for your main HTML file so users always get the freshest version that points to the new assets. -
Purge CDN on Deploy: Your deploy script must purge your CDN's cache for the HTML files (like
index.html) to force it to fetch the new version.
Source Maps
Source maps are essential for debugging, but they should never be shipped to the public. They contain your original, un-minified code. Host your source maps privately (e.g., upload them to Sentry, but don't deploy them to your public server) or disable them entirely for production if you don't have a private solution.
Cookies & Headers
- Trim Cookies: Never attach cookies to static asset paths (like your JS or CSS files). This is wasted overhead on every request.
- Security Headers: Implement a strong Content Security Policy (CSP) and other security headers (COEP/COOP), but tune them so they don't accidentally disable powerful browser caching or CDN optimizations.
Error Boundaries & Recovery
A JavaScript error that causes your entire React app to unmount and remount is a performance disaster. Use Error Boundaries to catch errors in parts of the UI, allowing you to fail gracefully (e.g., "Sorry, this widget couldn't load") without crashing the entire page.
Go Deeper: Build hygiene is the final enforcement layer. Research how to integrate Lighthouse CI or other performance budgeting tools (like size-limit) directly into your pull request checks. This turns these sections from a "guide" into a "non-negotiable rule" that automatically blocks regressions before they ever reach production.
Resource Hints Deep Dive
Give the browser stronger signals for prioritization and parallelization.
<link rel="preload" as="image" href="/images/hero.avif" imagesrcset="/images/hero.avif 1x, /images/hero@2x.avif 2x" fetchpriority="high" />
<link rel="modulepreload" href="/_next/static/chunks/chunk-abc123.js" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
Use the Speculation Rules API to prerender likely navigations.
<script type="speculationrules">
{
"prerender": [
{ "source": "document", "where": { "href_matches": ["/blog/*", "/projects/*"] } }
]
}
</script>
Tip: Reserve fetchpriority="high" for your LCP image only.
Fonts Deep Dive
Self-host variable fonts, subset, and preload only what renders above-the-fold.
<link rel="preload" as="font" href="/fonts/Inter-Var.woff2" type="font/woff2" crossorigin />
@font-face {
font-family: InterVar;
src: url('/fonts/Inter-Var.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: optional;
unicode-range: U+000-5FF; /* subset */
}
:root { font-family: InterVar, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
html { font-size-adjust: 0.5; }
Limit weights to what your design uses and prefer a single variable font to many static weights.
i18n / Font Performance
Internationalization impacts performance. **Split bundles per locale** and load only the font subsets required by the active language/script.
Locale‑Specific Bundle Splitting
Conditionally import locale code so users only download what they need, greatly reducing initial JS payload size.
// Dynamic import map by locale
const modules = {
en: () => import('./widgets/Widget.en.js'),
ar: () => import('./widgets/Widget.ar.js')
}
const locale = (document.documentElement.lang || 'en').slice(0,2)
const load = modules[locale] || modules.en
const { default: Widget } = await load()
Dynamic Font Subset Loading
Serve separate @font-face blocks per script with **unicode-range** , and preload only the subset for the current locale.
/* Latin subset with minimal unicode range */
@font-face {
font-family: 'InterIntl';
src: url('/fonts/InterIntl-latin.woff2') format('woff2');
font-weight: 400 700;
font-display: optional;
unicode-range: U+0000-00FF, U+0131; /* Simplified range for example */
}
/* Arabic subset with specific unicode range */
@font-face {
font-family: 'InterIntl';
src: url('/fonts/InterIntl-arabic.woff2') format('woff2');
font-weight: 400 700;
font-display: optional;
unicode-range: U+0600-06FF, U+0750-077F;
}
<!-- Server-side: emit the correct preload for the active locale -->
<link rel="preload" as="font" href="/fonts/InterIntl-latin.woff2" type="font/woff2" crossorigin />
// Client-side: Dynamic preload for non-critical subsets
const lang = (document.documentElement.lang || 'en').slice(0,2)
if (lang === 'ar') {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'font'
link.href = '/fonts/InterIntl-arabic.woff2'
link.type = 'font/woff2'
link.crossOrigin = 'anonymous'
document.head.appendChild(link)
}
Preloading & Compression
- **Use WOFF2** : It's already compressed and widely supported. Set
Content-Type: font/woff2and long-lived cache headers. - **Preload only above‑the‑fold fonts** : Emit a single
rel="preload"per critical subset; load the rest normally. - **Reduce variants** : Prefer a **variable font** over many static weights; subset per script with
unicode-range.
Tip: Keep i18n payloads small: lazy‑load locale messages and fonts, and avoid shipping all locales to every user by default.
Image Optimization: Recipes
Prefer picture for responsive formats and sizes.
<picture>
<source type="image/avif" srcset="hero.avif 1x, hero@2x.avif 2x" />
<source type="image/webp" srcset="hero.webp 1x, hero@2x.webp 2x" />
<img src="hero.jpg" width="1600" height="900" alt="Hero" loading="eager" fetchpriority="high" />
</picture>
// Next.js example
import Image from 'next/image'
<Image src="/images/hero.avif" alt="Hero" width={1600} height={900} priority sizes="(max-width: 768px) 100vw, 1600px" />
Defer off-screen work with CSS containment.
.section-below-fold {
content-visibility: auto;
contain-intrinsic-size: 800px;
}
INP Deep Dive
Capture INP and slow events in the field.
<script type="module">
import { onINP } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js'
onINP(({ value, attribution }) => {
console.log('INP', value, attribution)
// send to analytics
})
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.duration > 200) console.log('Slow input', e)
}
}).observe({ type: 'event', buffered: true })
</script>
Main-thread Offloading: Recipes
Move heavy work off the UI thread.
// worker.js
self.onmessage = (e) => { const data = heavyParse(e.data); self.postMessage(data); };
// main thread
const worker = new Worker('/worker.js', { type: 'module' });
worker.postMessage(bigJsonBlob);
worker.onmessage = ({ data }) => render(data);
// OffscreenCanvas starter
const off = new OffscreenCanvas(300, 150);
const ctx = off.getContext('2d');
// draw in worker, transfer via ImageBitmap
bfcache Correctness Patterns
Avoid unload; use modern lifecycle events.
addEventListener('pagehide', (e) => {
if (e.persisted) { /* paused in bfcache */ }
});
addEventListener('pageshow', (e) => {
if (e.persisted) { /* resume without re-fetching */ }
});
Third‑Party Discipline: Consent & Lite Embeds
Gate non-essential scripts and sandbox embeds.
function loadAnalytics(){
const s = document.createElement('script');
s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXX';
s.async = true;
document.head.appendChild(s);
}
consentButton.addEventListener('click', loadAnalytics);
<iframe loading="lazy" sandbox="allow-scripts allow-same-origin" src="/lite-youtube.html?id=VIDEO_ID" title="YouTube"></iframe>
CI Budgets & Tooling
Block regressions automatically with budgets and required checks.
Automated Lighthouse in CI
Run Lighthouse on each PR and fail when critical performance budgets are exceeded.
// .lighthouserc.js (Budget Configuration)
module.exports = {
ci: {
collect: { url: ['https://example.com/'] },
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
'unused-javascript': ['warn', { maxLength: 102400 }]
}
}
}
}
# .github/workflows/perf.yml (GitHub Action)
name: Performance CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Build/Start your app here
- run: npx @lhci/cli autorun
WebPageTest in CI (Lab Network)
Use WebPageTest for throttled, real-browser lab data; extract key metrics via command line.
# Example curl to get median WPT metrics (LCP, CLS, TBT)
curl -s "https://www.webpagetest.org/runtest.php?k=$WPT_API_KEY&url=...&f=json" \
| jq '.data.median.firstView | {LCP, CLS, TBT: .TotalBlockingTime}'
Bundle Size Budgets & Analysis
Keep JS in check with tools like `size-limit` and bundle analyzers.
// package.json size-limit check
{
"size-limit": [{ "path": "out/_next/static/chunks/*.js", "limit": "200 KB" }]
}
// next.config.js (Bundle Analyzer Integration)
const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true' })
module.exports = withBundleAnalyzer({})
Alerts for Metric Regressions
Notify your team when a PR degrades performance (e.g., via Slack).
# Example: Slack alert on Lighthouse job failure
notify:
needs: lighthouse
if: failure()
steps:
- name: Post to Slack
uses: slackapi/slack-github-action@v1.24.0
with: { payload: '{"text":"Performance regression detected in PR #${{ github.event.number }}."}' }
env: { SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} }
Tip: Make budgets required PR checks. Start generous and tighten as you pay off tech debt; alert on deltas (e.g., +10% LCP) not just absolutes.
CDN & Headers: Quick Wins
Cache aggressively for hashed assets; keep HTML fresh.
/* hashed assets */ Cache-Control: public, max-age=31536000, immutable
/* HTML */ Cache-Control: no-cache
Component Performance Guardrails
- Only animate
transform/opacity/scale; never layout properties. - No new DOM creation in scroll/touchmove handlers; throttle/debounce and recycle.
- Audit re-renders; use
React.memo/useCallback/useMemowhere profiling shows wins. - Above-the-fold images preloaded; below-the-fold images
loading="lazy". - Respect
prefers-reduced-motion.
Media Optimization (Video & Audio)
Video and audio can dominate payload and CPU. Optimize loading, playback, and visibility to protect **LCP** and **INP**.
Best Practices
- **Native player** : Use the HTML
videoelement (preferwebm+mp4) withpreload="metadata",playsinline, and aposter. Avoid auto-loading heavy players until user intent. - **Deferred loading** : Defer attaching sources until near-viewport using
IntersectionObserver. - **Autoplay discipline** : Autoplay only when
mutedandplaysinline; pause when off-screen. - **Multiple sources/ABR** : Provide
webmandmp4; consider adaptive streaming (HLS/DASH) with fallbacks.
Examples (Native & Lazy Loading)
<!-- 1. Native Player with Poster and Multiple Sources -->
<video controls playsinline preload="metadata" poster="/images/poster.jpg" width="1280" height="720"
data-src-webm="/videos/intro.webm" data-src-mp4="/videos/intro.mp4">
</video>
// 2. Lazy Loading and Autoplay Control with IntersectionObserver
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
const v = e.target
if (e.isIntersecting) {
// Attach source only when near viewport (Lazy Load)
if (v.dataset.srcMp4) {
v.innerHTML = `<source src="${v.dataset.srcWebm}" type="video/webm">` +
`<source src="${v.dataset.srcMp4}" type="video/mp4">`
v.load() // Load media
}
// Play when visible (Autoplay Discipline)
v.matches('.autoplay-when-visible') && v.play()
} else {
// Pause when off-screen
v.matches('.autoplay-when-visible') && v.pause()
}
}
}, { rootMargin: '200px', threshold: 0.25 })
document.querySelectorAll('video').forEach(v => io.observe(v))
Tip: For third-party players, use the same lite-embed pattern as iframes and load the heavy player only on click.
Memory & Leak Discipline
Unbounded memory growth causes jank and degraded responsiveness over time. Make cleanup and bounded caches non-negotiable.
Guardrails
- Abort in-flight requests on navigation/unmount (
AbortController). - Disconnect
MutationObserver/IntersectionObserver/ResizeObserveron teardown. - Use size-bounded caches (LRU); prefer
WeakMapfor ephemeral associations. - Clear timers (
setInterval/setTimeout) on pagehide or unmount.
Examples (Cleanup & Bounding)
// AbortController for fetch cleanup on unmount/timeout
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8000)
fetch('/api/data', { signal: controller.signal })
.finally(() => clearTimeout(timeout))
// Observer & Timer cleanup on pagehide (modern unload replacement)
const timerId = setInterval(work, 10000)
const obs = new MutationObserver(/* ... */)
obs.observe(document.body, { childList: true })
addEventListener('pagehide', () => {
clearInterval(timerId)
obs.disconnect()
}, { once: true })
// WeakMap for non-leaking element metadata
const meta = new WeakMap()
function tag(el, data) { meta.set(el, data) }
Tip: Use heap snapshots and allocation sampling to verify leaks are fixed, not just hidden.
Conclusion
You've just covered the first of our four pillars: Performance. The sections above are not just a checklist; they are a comprehensive framework for building web applications that are fast, responsive, and respectful of your user's device and data. Performance is a continuous loop of measuring, optimizing, and monitoring. It never ends, but it is the foundation upon which all other user experience is built.
This, however, is just the beginning. A site that is fast but unusable is still a failure.
This article is the first major part of our series. Next up, we will dive deep into the second pillar: Accessibility. We'll explore how to build applications that are usable by 100% of your audience, not just 80%. Following that, this series will also cover the remaining pillars: SEO & Discoverability and Modern Best Practices.
For now, take these 18 lessons and apply them. Don't try to fix everything at once. Pick one metric you're failing (like LCP), one asset type you're struggling with (like fonts), and one build tool you haven't mastered (like bundle analysis). Master them. Make high performance your new, non-negotiable default. Your users will thank you.
Top comments (0)