⚡ Performance Optimization

A fast website isn't just a nice-to-have — it directly affects user retention, conversion rates, and SEO rankings. Studies show that a 1-second delay in load time can reduce conversions by 7% and increase bounce rate by 32%. This page covers the core techniques every frontend developer should know.

Core Web Vitals

Google's Core Web Vitals are the three metrics that measure real user experience. They directly influence Google Search rankings, so improving them matters for both users and visibility.

LCP

Largest Contentful Paint

Time until the largest visible element loads.
Good: under 2.5s

CLS

Cumulative Layout Shift

How much the page jumps around during load.
Good: under 0.1

INP

Interaction to Next Paint

How fast the page responds to clicks/taps.
Good: under 200ms

🔧 How to measure
  • Chrome DevTools → Lighthouse — run an audit in one click, scores all three
  • DevTools → Performance tab — record and inspect frame-by-frame
  • PageSpeed Insights — uses real-world data from Chrome users
  • WebPageTest — detailed waterfall, filmstrip, comparison tests

The Critical Rendering Path

Understanding how browsers turn HTML into pixels is the foundation of performance work. Every optimization maps back to shortening one of these steps.

  1. Parse HTML → build the DOM tree. Stops whenever it hits a blocking resource.
  2. Parse CSS → build the CSSOM. Blocks rendering until complete.
  3. Combine DOM + CSSOM → build the Render Tree (only visible elements).
  4. Layout → calculate position and size of every element.
  5. Paint → fill in pixels (colors, images, shadows).
  6. Composite → combine layers and display to the screen.
📝 What "render-blocking" means

By default, <script> and <link rel="stylesheet"> pause HTML parsing while they download and execute. The browser can't show anything until they finish. Your job is to minimize, defer, or eliminate these blocks.

Script Loading: async, defer, type="module"

How you load JavaScript has a massive impact on page speed. Never put scripts in <head> without a loading strategy.

HTML parsing: [====BLOCKED========] script downloads & runs <script src="app.js"></script> HTML parsing: [========CONTINUES======] [script runs after parse] <script defer src="app.js"></script> HTML parsing: [========CONTINUES======] [script runs when ready] <script async src="analytics.js"></script>
<!-- ❌ Blocks HTML parsing — browser stops and waits -->
<head>
  <script src="app.js"></script>
</head>

<!-- ✅ defer: downloads in parallel, runs after HTML is parsed
        preserves execution order between scripts -->
<script defer src="app.js"></script>

<!-- ✅ async: downloads in parallel, runs immediately when ready
        no guaranteed order — best for independent scripts like analytics -->
<script async src="analytics.js"></script>

<!-- ✅ type="module": always deferred, supports import/export -->
<script type="module" src="app.js"></script>
AttributeDownloadsExecutesOrder preservedUse for
(none) Blocks parse Immediately Yes Avoid
defer Parallel After HTML parsed Yes App scripts
async Parallel When ready No Analytics, ads
type="module" Parallel After HTML parsed Yes Modern apps

Resource Hints

Resource hints let you tell the browser about resources it will need before it discovers them naturally, giving it a head start.

<head>
  <!-- preload: download this ASAP — use for LCP image, hero font, critical CSS -->
  <link rel="preload" as="image" href="hero.webp">
  <link rel="preload" as="font" href="font.woff2" crossorigin>

  <!-- prefetch: download when browser is idle — use for next-page resources -->
  <link rel="prefetch" href="dashboard.js">

  <!-- preconnect: open a connection early — use for CDNs and third-party origins -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

  <!-- dns-prefetch: cheaper than preconnect — resolves DNS early -->
  <link rel="dns-prefetch" href="https://api.example.com">
</head>
HintWhat it doesUse when
preload Downloads the resource at high priority LCP image, hero font, critical script
prefetch Downloads at low priority when idle Resources needed on the next page
preconnect Opens TCP + TLS connection early Any third-party origin (fonts, CDN, API)
dns-prefetch Resolves DNS only (lighter than preconnect) Origins you'll connect to later, not immediately
⚠️ Don't overuse preload

Preloading too many resources defeats the purpose — everything becomes "high priority" which means nothing is. Preload only 2–3 truly critical above-the-fold resources.

CSS Performance

Avoid triggering expensive layout operations

Some CSS changes force the browser to recalculate layouts for the entire page (reflow). Others only require repainting pixels. The cheapest changes happen on the compositor thread and skip both.

CostOperationsExamples
🔴 High (reflow) Change geometry width, height, margin, padding, font-size, top, left
🟡 Medium (repaint) Change appearance only color, background-color, border-color, box-shadow
🟢 Cheap (composite only) Move/fade layers transform, opacity
Prefer transform over position for animations
/* ❌ Triggers reflow on every frame — causes jank */
@keyframes slide-bad {
  from { left: 0; }
  to   { left: 200px; }
}

/* ✅ Compositor-only — silky 60fps */
@keyframes slide-good {
  from { transform: translateX(0); }
  to   { transform: translateX(200px); }
}

/* ❌ Animating width causes reflow — janky transitions */
.menu { transition: width 0.3s; }

/* ✅ Animate transform instead */
.menu { transition: transform 0.3s; }

will-change — promote an element to its own layer

/* Hints the browser to create a GPU layer before animation starts
   so it doesn't have to do it on the first frame */
.modal {
  will-change: transform, opacity;
}

/* Remove it after the animation — layers consume GPU memory */
.modal.is-hidden {
  will-change: auto;
}
⚠️ Don't add will-change everywhere

Each will-change layer consumes GPU memory. Only use it on elements that actually animate, and only when you can measure a real improvement. Overuse can make performance worse.

Critical CSS — inline above-the-fold styles

The browser can't render anything until all CSS is downloaded. For fast first paints, inline the CSS needed for above-the-fold content directly in <head> and load the rest asynchronously.

<head>
  <!-- Critical CSS inlined — no network round-trip needed -->
  <style>
    body { margin: 0; font-family: sans-serif; }
    .hero { height: 100dvh; display: flex; align-items: center; }
    .nav  { position: fixed; top: 0; width: 100%; }
  </style>

  <!-- Non-critical CSS loaded asynchronously -->
  <link rel="stylesheet" href="styles.css" media="print"
        onload="this.media='all'">
  <noscript>
    <link rel="stylesheet" href="styles.css">
  </noscript>
</head>

JavaScript Performance Patterns

Debounce — limit how often a function fires

Some events fire dozens of times per second — scroll, resize, keyup. Running heavy logic on every single event causes lag. Debouncing waits until the event has stopped firing for a set delay before running the function.

// Debounce: wait until the user stops typing for 300ms
function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// Without debounce: search fires on every keystroke
input.addEventListener('input', searchAPI); // ❌ too many requests

// With debounce: search fires 300ms after the user stops typing
const debouncedSearch = debounce(searchAPI, 300);
input.addEventListener('input', debouncedSearch); // ✅

Throttle — cap the rate a function can fire

Throttling guarantees a function runs at most once every N milliseconds, no matter how often the event fires. Use it when you need regular updates (scroll position, mouse tracking) but not on every single frame.

// Throttle: run at most once every 100ms
function throttle(fn, limit) {
  let lastCall = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastCall >= limit) {
      lastCall = now;
      fn.apply(this, args);
    }
  };
}

// Update progress bar on scroll — throttled to 10 times/sec max
const throttledScroll = throttle(updateProgressBar, 100);
window.addEventListener('scroll', throttledScroll);

// Rule of thumb:
// Debounce → "after it stops" (search, resize, form validation)
// Throttle → "while it's happening, but slower" (scroll, mousemove)

Avoid layout thrashing

Layout thrashing happens when JavaScript alternates between reading and writing DOM geometry, forcing the browser to recalculate layout repeatedly inside the same frame.

// ❌ Layout thrashing — read/write/read/write alternating
elements.forEach(el => {
  const width = el.offsetWidth;   // READ  → forces layout
  el.style.width = width + 10 + 'px'; // WRITE → invalidates layout
  const height = el.offsetHeight; // READ  → forces layout again!
  el.style.height = height + 10 + 'px';
});

// ✅ Batch reads first, then writes
const measurements = elements.map(el => ({
  width:  el.offsetWidth,   // READ all
  height: el.offsetHeight
}));

elements.forEach((el, i) => {
  el.style.width  = measurements[i].width  + 10 + 'px'; // WRITE all
  el.style.height = measurements[i].height + 10 + 'px';
});

requestAnimationFrame for visual updates

// ❌ setTimeout for animation — doesn't sync with the screen refresh
setTimeout(animate, 16); // might fire at the wrong moment

// ✅ requestAnimationFrame — syncs with the browser's repaint cycle
//    runs at 60fps (or whatever the screen supports), pauses when tab is hidden
function animate(timestamp) {
  // update positions using timestamp for smooth, time-based motion
  element.style.transform = `translateX(${timestamp * 0.1 % 400}px)`;
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

Memoization — cache expensive results

// ❌ Recalculates every time, even with the same input
function expensiveCalc(n) {
  /* complex logic */
  return result;
}

// ✅ Returns cached result for repeated inputs
function memoize(fn) {
  const cache = new Map();
  return function(n) {
    if (cache.has(n)) return cache.get(n);
    const result = fn(n);
    cache.set(n, result);
    return result;
  };
}

const fastCalc = memoize(expensiveCalc);
fastCalc(42); // calculated
fastCalc(42); // returned from cache instantly

Event delegation — one listener instead of many

// ❌ One listener per item — slow for large lists, breaks on dynamic items
document.querySelectorAll('.item').forEach(item => {
  item.addEventListener('click', handleClick);
});

// ✅ One listener on the parent — handles all current AND future children
document.querySelector('.list').addEventListener('click', (e) => {
  if (e.target.closest('.item')) {
    handleClick(e);
  }
});

Network and Asset Optimization

Minification and bundling

Minification removes whitespace, comments, and shortens variable names. Bundling combines many files into fewer requests. Modern build tools (Vite, webpack, esbuild) do both automatically.

/* Before minification — 312 bytes */
.navigation {
  display: flex;
  align-items: center;
  background-color: #ffffff;
}

/* After minification — 60 bytes */
.navigation{display:flex;align-items:center;background-color:#fff}

HTTP caching

The browser can store assets locally and reuse them on repeat visits, eliminating network requests entirely.

/* Server response headers — set by your server/CDN config */

/* HTML: short cache — content changes frequently */
Cache-Control: no-cache, must-revalidate

/* CSS/JS with content hash in filename: cache forever
   When code changes, the filename changes → new URL → new download */
Cache-Control: public, max-age=31536000, immutable

/* Images: cache for a week */
Cache-Control: public, max-age=604800
💡 Content hashing

Build tools like Vite output files named app.3f8a2c1d.js where the hash changes whenever the code changes. This lets you serve those files with a permanent cache while still getting instant updates when you deploy.

Tree shaking — eliminate dead code

// ❌ Imports entire library — all 70kb included in bundle
import _ from 'lodash';
_.debounce(fn, 300);

// ✅ Named import — bundler includes ONLY debounce (~2kb)
import { debounce } from 'lodash-es';
debounce(fn, 300);

// ✅ Even better — write it yourself (zero dependencies)
const debouncedFn = debounce(fn, 300); // your own implementation

Lazy loading routes and components

// ❌ Everything loads upfront — large initial bundle
import Dashboard from './Dashboard';
import Settings  from './Settings';
import Profile   from './Profile';

// ✅ Dynamic import — each route loads only when navigated to
const Dashboard = () => import('./Dashboard');
const Settings  = () => import('./Settings');
const Profile   = () => import('./Profile');

Font Optimization

Web fonts are a common performance culprit — they block text rendering until downloaded.

/* 1. Preconnect to the font origin */
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

/* 2. font-display: swap — show fallback font immediately,
      swap in the web font when it arrives */
@font-face {
  font-family: 'Inter';
  src: url('inter.woff2') format('woff2');
  font-display: swap; /* prevents invisible text during load */
}

/* 3. Only load the weights and styles you actually use */
/* ❌ Loads all 9 weights */
@import url('https://fonts.googleapis.com/css2?family=Inter');

/* ✅ Loads only weight 400 and 700 */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700');

/* 4. Self-host fonts for the best performance — no third-party request */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-400.woff2') format('woff2');
  font-weight: 400;
  font-display: swap;
}

Images — Quick Checklist

See the Responsive Images page for full detail. Key points:

📋 Image performance checklist
  • Always set width and height attributes to prevent layout shift
  • Serve AVIF → WebP → JPEG via <picture>
  • Use srcset + sizes to serve appropriately-sized images
  • Add loading="lazy" to all below-the-fold images
  • Add fetchpriority="high" to your LCP image (hero)
  • Compress images before uploading — tools: Squoosh, ImageOptim, Sharp
  • Use SVG for icons and logos — infinitely scalable, tiny file size

Measuring and Diagnosing Performance

Lighthouse audit

// In Chrome DevTools:
// 1. Open DevTools (F12)
// 2. Go to "Lighthouse" tab
// 3. Select "Performance" category
// 4. Click "Analyze page load"

// Scores to aim for:
// Performance:    90+
// Accessibility:  90+
// Best Practices: 90+
// SEO:            90+

Performance tab — find the bottleneck

🔧 How to use the Performance tab
  1. Open DevTools → Performance tab
  2. Click the record button, interact with the page, stop recording
  3. Look for long tasks (red bars) — these block the main thread
  4. Check the Frames row — drops below 60fps cause visual jank
  5. Inspect the flame chart to find exactly which function is slow

Network tab — spot heavy resources

🔧 Network tab workflow
  1. Open DevTools → Network tab, reload the page
  2. Sort by Size — find unexpectedly large files
  3. Sort by Time — find slow requests
  4. Use the Waterfall column — long bars = slow, gaps = waiting
  5. Enable throttling (Fast 3G) to simulate mobile network conditions

Optimization Checklist

📋 Before you ship — performance checklist

HTML

  • All <script> tags use defer or async
  • Hero image has fetchpriority="high", all others have loading="lazy"
  • LCP image is preloaded with <link rel="preload">
  • preconnect added for all third-party origins (fonts, CDN, APIs)
  • All images have width, height, and meaningful alt

CSS

  • Animations use only transform and opacity
  • Critical above-the-fold CSS is inlined or loaded with high priority
  • Unused CSS is removed (PurgeCSS, manual audit)
  • Font faces use font-display: swap
  • Only the font weights/styles actually used are loaded

JavaScript

  • Event listeners on frequently-firing events are debounced or throttled
  • DOM reads and writes are batched separately (no layout thrashing)
  • Animations use requestAnimationFrame
  • Named imports used instead of full library imports (tree shaking)
  • Large routes/components loaded with dynamic import()

Assets

  • Images served as AVIF/WebP with JPEG fallback
  • All images compressed before upload
  • CSS and JS minified (handled by build tool)
  • Static assets cached with long max-age + content hashes in filenames
  • Lighthouse score above 90 for Performance