Learning Guides
Menu

Performance Optimization

12 min readFrontend Patterns & Concepts

Performance Optimization

Performance isn't optional—it's a feature. Slow sites lose users, hurt SEO, and frustrate everyone. This chapter covers the techniques that matter most.

Core Web Vitals

Google's metrics that measure real user experience.

Largest Contentful Paint (LCP)

Measures loading performance—when the largest content element becomes visible.

PLAINTEXT
Target: < 2.5 seconds
 
Common Causes of Poor LCP:
├── Slow server response (TTFB)
├── Render-blocking CSS/JS
├── Slow resource loading
└── Client-side rendering delay
JAVASCRIPT
// Measure LCP
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
 
  console.log("LCP:", lastEntry.startTime, "ms");
  console.log("LCP Element:", lastEntry.element);
 
  // Report to analytics
  analytics.track("LCP", { value: lastEntry.startTime });
}).observe({ type: "largest-contentful-paint", buffered: true });

Optimizations:

HTML
<!-- Preload LCP image -->
<link rel="preload" href="/hero.jpg" as="image" fetchpriority="high" />
 
<!-- Use fetchpriority for critical resources -->
<img src="/hero.jpg" fetchpriority="high" alt="Hero" />
 
<!-- Avoid lazy loading above-the-fold content -->
<img src="/hero.jpg" loading="eager" />
 
<!-- Inline critical CSS -->
<style>
  /* Critical styles for above-the-fold */
  .hero {
    /* ... */
  }
</style>

First Input Delay (FID) / Interaction to Next Paint (INP)

Measures interactivity—how quickly the page responds to user input.

PLAINTEXT
Target: < 100ms FID, < 200ms INP
 
Common Causes of Poor FID/INP:
├── Long JavaScript tasks
├── Heavy main thread work
├── Large JavaScript bundles
└── Third-party scripts
JAVASCRIPT
// Measure FID
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("FID:", entry.processingStart - entry.startTime, "ms");
  }
}).observe({ type: "first-input", buffered: true });
 
// Measure INP (all interactions)
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.interactionId) {
      const duration = entry.processingEnd - entry.startTime;
      console.log("Interaction:", entry.name, duration, "ms");
    }
  }
}).observe({ type: "event", buffered: true });

Optimizations:

JAVASCRIPT
// Break up long tasks
function processLargeArray(items) {
  const CHUNK_SIZE = 100;
  let index = 0;
 
  function processChunk() {
    const chunk = items.slice(index, index + CHUNK_SIZE);
    chunk.forEach((item) => processItem(item));
 
    index += CHUNK_SIZE;
 
    if (index < items.length) {
      // Yield to browser, then continue
      requestIdleCallback(processChunk);
    }
  }
 
  processChunk();
}
 
// Use requestIdleCallback for non-critical work
requestIdleCallback(
  (deadline) => {
    while (deadline.timeRemaining() > 0 && tasks.length > 0) {
      const task = tasks.shift();
      task();
    }
  },
  { timeout: 2000 },
);
 
// Defer non-critical JavaScript
<script src="analytics.js" defer></script>;

Cumulative Layout Shift (CLS)

Measures visual stability—how much the page shifts unexpectedly.

PLAINTEXT
Target: < 0.1
 
Common Causes of Poor CLS:
├── Images without dimensions
├── Ads/embeds without reserved space
├── Dynamically injected content
├── Web fonts causing FOIT/FOUT
└── Animations that trigger layout
JAVASCRIPT
// Measure CLS
let clsValue = 0;
let clsEntries = [];
 
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Only count if not from user interaction
    if (!entry.hadRecentInput) {
      clsValue += entry.value;
      clsEntries.push(entry);
    }
  }
  console.log("CLS:", clsValue);
}).observe({ type: "layout-shift", buffered: true });

Optimizations:

HTML
<!-- Always include image dimensions -->
<img src="photo.jpg" width="800" height="600" alt="Photo" />
 
<!-- Or use aspect-ratio CSS -->
<style>
  .image-container {
    aspect-ratio: 16 / 9;
    width: 100%;
  }
</style>
 
<!-- Reserve space for ads -->
<div class="ad-slot" style="min-height: 250px;">
  <!-- Ad loads here -->
</div>
 
<!-- Avoid inserting content above existing content -->
<!-- Use transforms instead of layout-triggering properties -->
<style>
  .animate {
    transform: translateY(10px); /* Good */
    /* top: 10px; Bad - triggers layout */
  }
</style>

JavaScript Performance

JavaScript is often the biggest bottleneck in web performance. Unlike HTML and CSS which can be parsed incrementally, JS must be downloaded, parsed, compiled, and executed before it can do anything. Understanding how to optimize JS delivery and execution is critical.

Code Splitting

By default, bundlers create a single JavaScript file containing your entire application. Users must download ALL the code before seeing ANYTHING—even for pages they'll never visit.

Code splitting breaks your bundle into smaller chunks that load on-demand:

Benefits:

  • Faster initial page load (smaller initial bundle)
  • Users only download code they actually need
  • Better caching (unchanged chunks stay cached)
  • Parallel loading of chunks

Common splitting strategies:

  • Route-based: Each page is a separate chunk
  • Component-based: Heavy components loaded when needed
  • Interaction-based: Load code when user triggers an action
JAVASCRIPT
// Route-based splitting
const routes = {
  "/": () => import("./pages/Home"),
  "/products": () => import("./pages/Products"),
  "/cart": () => import("./pages/Cart"),
};
 
// Component-based splitting
const HeavyChart = lazy(() => import("./components/Chart"));
 
// Load on interaction
button.addEventListener("click", async () => {
  const { Modal } = await import("./components/Modal");
  showModal(Modal);
});

Tree Shaking

Tree shaking is the process of eliminating dead code—code that's imported but never actually used. Modern bundlers (Webpack, Rollup, esbuild) analyze your imports and exclude unused exports from the final bundle.

Why this matters: Lodash has 300+ functions. If you use debounce, you shouldn't ship all 300 functions to users. Tree shaking keeps only what you use.

Requirements for tree shaking:

  • Use ES modules (import/export), not CommonJS (require)
  • Avoid side effects in module scope
  • Use libraries that support tree shaking (look for "sideEffects": false in package.json)
JAVASCRIPT
// ❌ Imports entire library
import _ from "lodash";
_.debounce(fn, 100);
 
// ✅ Tree-shakeable import
import debounce from "lodash-es/debounce";
debounce(fn, 100);
 
// ✅ Named imports (if library supports it)
import { debounce } from "lodash-es";

Avoiding Memory Leaks

Memory leaks occur when your application holds references to objects that are no longer needed, preventing garbage collection. Over time, this causes the page to slow down and eventually crash.

Common sources of memory leaks in React/JS:

  • Event listeners not removed when components unmount
  • Timers (setInterval, setTimeout) not cleared
  • Closures that capture large objects
  • Subscriptions not unsubscribed
  • DOM references held after elements are removed

How to detect memory leaks:

  1. Open Chrome DevTools → Memory tab
  2. Take heap snapshot before and after an action
  3. Compare to find objects that should have been cleaned up
  4. Use the "Allocation timeline" to see what's being allocated over time
JAVASCRIPT
// ❌ Event listener not cleaned up
useEffect(() => {
  window.addEventListener("resize", handleResize);
  // Missing cleanup!
}, []);
 
// ✅ Proper cleanup
useEffect(() => {
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);
 
// ❌ Timer not cleared
useEffect(() => {
  setInterval(pollData, 5000);
}, []);
 
// ✅ Timer cleared on unmount
useEffect(() => {
  const id = setInterval(pollData, 5000);
  return () => clearInterval(id);
}, []);
 
// ❌ Closure holds reference
function createHandler() {
  const largeData = new Array(1000000);
  return () => {
    // largeData never garbage collected while handler exists
    console.log(largeData.length);
  };
}
 
// ✅ Release reference when done
function createHandler() {
  let largeData = new Array(1000000);
  return () => {
    if (largeData) {
      console.log(largeData.length);
      largeData = null; // Allow GC
    }
  };
}

Rendering Performance

The browser's rendering pipeline has distinct phases: Style → Layout → Paint → Composite. Understanding this helps you avoid expensive operations.

Layout (Reflow): Calculating size and position of elements. EXPENSIVE. Paint: Drawing pixels (colors, shadows, text). Moderate cost. Composite: Combining layers. CHEAP (GPU-accelerated).

The key optimization: trigger only composite, avoid layout/paint when possible.

Avoiding Forced Reflows

A forced reflow happens when you read a layout property (like offsetWidth) after modifying styles. The browser must stop everything and recalculate layout to give you an accurate value.

Layout-triggering properties:

  • offsetWidth, offsetHeight, offsetTop
  • clientWidth, clientHeight
  • scrollWidth, scrollHeight
  • getComputedStyle()
  • getBoundingClientRect()

The pattern that kills performance:

PLAINTEXT
Read (forces layout) → Write → Read (forces layout AGAIN) → Write...

The fix: Batch all reads together, then batch all writes.

JAVASCRIPT
// ❌ Causes multiple forced reflows
elements.forEach((el) => {
  el.style.width = el.offsetWidth + 10 + "px"; // Read then write
});
 
// ✅ Batch reads and writes
const widths = elements.map((el) => el.offsetWidth); // All reads first
elements.forEach((el, i) => {
  el.style.width = widths[i] + 10 + "px"; // Then all writes
});
 
// ✅ Use requestAnimationFrame
function animate() {
  // Reads
  const scrollY = window.scrollY;
 
  requestAnimationFrame(() => {
    // Writes
    elements.forEach((el) => {
      el.style.transform = `translateY(${scrollY * 0.5}px)`;
    });
  });
}

CSS Containment

When the browser needs to recalculate layout or paint, it normally has to consider the entire page. CSS containment tells the browser that an element's internals are independent—changes inside won't affect anything outside.

Why this helps:

  • Browser can skip recalculating unaffected areas
  • Enables off-screen rendering optimizations
  • Reduces scope of expensive operations

Containment types:

  • layout: Element's layout is independent
  • paint: Element's painting is independent
  • style: Counters and quotes don't affect outside
  • size: Element's size is independent of children

content-visibility: auto is particularly powerful—it skips rendering for off-screen content entirely, dramatically improving initial render time for long pages.

CSS
/* Tell browser this element's internals don't affect outside */
.card {
  contain: layout style paint;
  /* Or shorthand: */
  contain: strict;
}
 
/* Content-visibility for off-screen content */
.below-fold {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* Estimated size */
}

Will-Change

The will-change property hints to the browser that an element will be animated or transformed soon. The browser can then promote the element to its own compositing layer and prepare optimizations in advance.

When to use:

  • Just before an animation starts (via JavaScript)
  • For elements that will definitely be animated
  • Only when you've identified a specific performance issue

When NOT to use:

  • On every element "just in case"
  • Permanently on elements (remove after animation)
  • On too many elements (each layer uses GPU memory)

Best practice: Add will-change via JavaScript just before the animation, remove it when done.

CSS
/* Hint to browser for optimization */
.animated {
  will-change: transform;
}
 
/* Remove after animation */
.animated.done {
  will-change: auto;
}
 
/* Don't overuse - has memory cost */
/* ❌ Bad */
* {
  will-change: transform;
}

GPU-Accelerated Properties

Some CSS properties can be animated by the GPU without involving the main thread or causing reflows. These are called compositor-only properties:

  • transform (translate, rotate, scale, skew)
  • opacity

Why these are special:

  • They don't affect layout (no reflow)
  • They're handled entirely by the compositor (GPU)
  • Animations stay smooth even if main thread is busy

The rule: If you can achieve an effect with transform or opacity, do it. Avoid animating width, height, top, left, margin, or padding.

CSS
/* Prefer transform/opacity - GPU accelerated */
.slide-in {
  transform: translateX(0);
  opacity: 1;
  transition:
    transform 0.3s,
    opacity 0.3s;
}
 
.slide-in.hidden {
  transform: translateX(-100%);
  opacity: 0;
}
 
/* Avoid animating layout properties */
/* ❌ Triggers layout */
.bad {
  transition:
    width 0.3s,
    height 0.3s,
    top 0.3s,
    left 0.3s;
}

Image Optimization

Modern Formats

HTML
<picture>
  <!-- AVIF: Best compression, limited support -->
  <source srcset="image.avif" type="image/avif" />
  <!-- WebP: Good compression, wide support -->
  <source srcset="image.webp" type="image/webp" />
  <!-- JPEG: Fallback -->
  <img src="image.jpg" alt="Description" />
</picture>

Responsive Images

HTML
<img
  srcset="image-400.jpg 400w, image-800.jpg 800w, image-1200.jpg 1200w"
  sizes="
    (max-width: 400px) 400px,
    (max-width: 800px) 800px,
    1200px
  "
  src="image-800.jpg"
  alt="Description"
  loading="lazy"
  decoding="async"
/>

Lazy Loading

JAVASCRIPT
// Native lazy loading
<img src="photo.jpg" loading="lazy" alt="Photo">
 
// For background images and more control
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
}, { rootMargin: '100px' });
 
document.querySelectorAll('img[data-src]').forEach(img => {
  observer.observe(img);
});

Caching Strategies

HTTP Caching

PLAINTEXT
Cache-Control Headers:
├── max-age=31536000, immutable     (versioned assets)
├── max-age=0, must-revalidate      (dynamic content)
├── private, max-age=3600           (user-specific)
└── no-store                        (sensitive data)

Service Worker Caching

JAVASCRIPT
// Stale-while-revalidate pattern
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.open("v1").then((cache) => {
      return cache.match(event.request).then((cached) => {
        const fetched = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });
 
        return cached || fetched;
      });
    }),
  );
});

Bundle Analysis

Analyzing Bundle Size

BASH
# Webpack
npx webpack-bundle-analyzer stats.json
 
# Vite
npx vite-bundle-visualizer
 
# Next.js
ANALYZE=true npm run build

Finding Heavy Dependencies

JAVASCRIPT
// Import cost analysis in VS Code
// Shows import size inline
 
import moment from "moment"; // 67KB!
import dayjs from "dayjs"; // 2KB ✓
 
import _ from "lodash"; // 70KB!
import { debounce } from "lodash-es"; // 1KB ✓

Profiling Tools

Chrome DevTools Performance Panel

JAVASCRIPT
// Programmatic performance marks
performance.mark("function-start");
 
expensiveFunction();
 
performance.mark("function-end");
performance.measure("expensive-function", "function-start", "function-end");
 
// View in Performance panel
const measures = performance.getEntriesByType("measure");
console.log(measures);

React DevTools Profiler

JAVASCRIPT
import { Profiler } from "react";
 
function onRenderCallback(
  id, // Tree ID
  phase, // "mount" or "update"
  actualDuration, // Time spent rendering
  baseDuration, // Estimated time without memoization
  startTime,
  commitTime,
) {
  console.log(`${id} ${phase}: ${actualDuration}ms`);
}
 
<Profiler id="MyComponent" onRender={onRenderCallback}>
  <MyComponent />
</Profiler>;

Web Vitals Library

JAVASCRIPT
import { getLCP, getFID, getCLS, getTTFB, getFCP } from "web-vitals";
 
function sendToAnalytics({ name, value, id }) {
  gtag("event", name, {
    event_category: "Web Vitals",
    event_label: id,
    value: Math.round(name === "CLS" ? value * 1000 : value),
    non_interaction: true,
  });
}
 
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);
getTTFB(sendToAnalytics);
getFCP(sendToAnalytics);

Performance Budget

Setting Budgets

JAVASCRIPT
// Example performance budget
const budget = {
  // Timing budgets
  "first-contentful-paint": 1800, // ms
  "largest-contentful-paint": 2500, // ms
  "cumulative-layout-shift": 0.1,
  "first-input-delay": 100, // ms
 
  // Size budgets
  "total-bundle-size": 250, // KB
  "initial-js": 150, // KB
  "initial-css": 50, // KB
  images: 500, // KB
 
  // Request budgets
  requests: 50,
  "third-party-requests": 10,
};

Enforcing Budgets

JAVASCRIPT
// Lighthouse CI configuration
module.exports = {
  ci: {
    assert: {
      assertions: {
        "first-contentful-paint": ["error", { maxNumericValue: 1800 }],
        "largest-contentful-paint": ["error", { maxNumericValue: 2500 }],
        "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }],
        "total-blocking-time": ["warning", { maxNumericValue: 300 }],
      },
    },
  },
};

Summary

MetricTargetKey Optimizations
LCP< 2.5sPreload hero image, inline critical CSS
FID/INP< 100msCode split, defer non-critical JS
CLS< 0.1Specify image dimensions, reserve space
TTFB< 800msCDN, caching, optimize server
Bundle Size< 250KBTree shaking, code splitting

Key Principles:

  1. Measure first, optimize second
  2. Focus on user-perceived performance
  3. Set and enforce performance budgets
  4. Monitor real user metrics (RUM)
  5. Performance is a continuous process