JavaScript Performance Optimization Techniques
JavaScript Performance Optimization TechniquesJanuary 15, 2024

JavaScript Performance Optimization Techniques

Deep dive into JavaScript performance optimization. Covers memory management, event loop understanding, code splitting, lazy loading, and profiling techniques to build faster web applications.

Table of Contents

  1. Why JavaScript Performance Matters
  2. Understanding the Event Loop
  3. Memory Management
  4. Avoiding Layout Thrashing
  5. Debounce, Throttle, and Scheduling
  6. Code Splitting and Lazy Loading
  7. Web Workers for Heavy Work
  8. Profiling in Practice
  9. Checklist

Why JavaScript Performance Matters {#why}

On a high-traffic e-commerce site, JavaScript is often the largest source of delay after the first paint. Slow scripts delay interactivity, inflate Time to Interactive, and make checkout feel broken even when the network is fine.

The goal is not "as little JavaScript as possible." It is the right JavaScript, at the right time, without blocking the main thread.

What to optimize first

  • Main-thread blocking: long tasks over 50ms
  • Unnecessary work: duplicate fetches, extra renders, unused listeners
  • Payload size: parse + compile cost grows with bundle size
  • Memory: leaks show up as jank after a few minutes of browsing

Understanding the Event Loop {#event-loop}

The browser has one main thread for JavaScript, style, layout, and paint. Long synchronous work starves input and animation.

// Bad: one 200ms task blocks clicks and paint
function processOrders(orders) {
  return orders.map(enrichOrder).filter(isValid);
}

// Better: yield between chunks so the page stays responsive
async function processOrdersInChunks(orders, chunkSize = 50) {
  const result = [];

  for (let i = 0; i < orders.length; i += chunkSize) {
    const chunk = orders.slice(i, i + chunkSize);
    result.push(...chunk.map(enrichOrder).filter(isValid));
    await new Promise((resolve) => setTimeout(resolve, 0));
  }

  return result;
}

Prefer scheduler.yield() or requestIdleCallback when you need to do background work without competing with user input.

Microtasks vs macrotasks

Promises and queueMicrotask run before the next paint. Flooding the microtask queue can still freeze the UI:

// Dangerous: never yields to rendering
async function tightLoop() {
  while (hasMoreWork()) {
    await Promise.resolve(); // still a microtask
    doWork();
  }
}

Memory Management {#memory}

JavaScript is garbage collected, but you can still leak memory.

Common leaks in SPAs

  • Event listeners not removed on unmount
  • Subscriptions (WebSocket, analytics, ResizeObserver) left open
  • Closures holding large DOM nodes or datasets
  • Global caches that grow without eviction
import { useEffect } from 'react';

function LiveInventory({ sku }) {
  useEffect(() => {
    const controller = new AbortController();
    const observer = new ResizeObserver(measure);

    fetch(`/api/inventory/${sku}`, { signal: controller.signal });
    observer.observe(document.body);

    return () => {
      controller.abort();
      observer.disconnect();
    };
  }, [sku]);

  return null;
}

Object pooling is rarely the first fix

Before pooling, shrink retained data. Keep list items as IDs plus a lookup map. Avoid storing derived arrays next to source arrays if both stay in memory.

Avoiding Layout Thrashing {#layout}

Reading layout (offsetHeight, getBoundingClientRect) after writing styles forces the browser to recalculate immediately.

// Forces layout N times
items.forEach((el) => {
  el.style.width = el.offsetWidth + 10 + 'px';
});

// Batch reads, then writes
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => {
  el.style.width = widths[i] + 10 + 'px';
});

Use CSS for animations (transform and opacity) instead of animating top, left, or width.

Debounce, Throttle, and Scheduling {#scheduling}

Search inputs, resize handlers, and scroll listeners should not run on every event.

function debounce(fn, wait) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

function throttle(fn, wait) {
  let last = 0;
  let timer;
  return (...args) => {
    const now = Date.now();
    const remaining = wait - (now - last);

    if (remaining <= 0) {
      clearTimeout(timer);
      last = now;
      fn(...args);
    } else if (!timer) {
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn(...args);
      }, remaining);
    }
  };
}

const onSearch = debounce((query) => fetchSuggestions(query), 250);
const onScroll = throttle(updateStickyHeader, 16);

For React, wrap these in useMemo/useCallback or use a library that cancels pending calls on unmount.

Code Splitting and Lazy Loading {#splitting}

Ship less JavaScript on the first load. Split by route first, then by heavy widgets (charts, editors, PDFs).

import { lazy, Suspense } from 'react';

const CheckoutReview = lazy(() => import('./CheckoutReview'));

export function CheckoutPage({ step }) {
  return (
    <Suspense fallback={<div>Loading checkout...</div>}>
      {step === 'review' ? <CheckoutReview /> : <ShippingForm />}
    </Suspense>
  );
}

Dynamic import() for feature flags works the same way:

async function trackPurchase(payload) {
  const { analytics } = await import('./analytics');
  analytics.purchase(payload);
}

Preload the next likely route on hover or when the current page is idle.

Web Workers for Heavy Work {#workers}

Move CPU-heavy work off the main thread: CSV parsing, search indexes, image processing, large JSON transforms.

// inventory.worker.js
self.onmessage = ({ data }) => {
  const indexed = buildSearchIndex(data.products);
  self.postMessage(indexed);
};
const worker = new Worker(new URL('./inventory.worker.js', import.meta.url));

worker.postMessage({ products });
worker.onmessage = ({ data }) => setIndex(data);

Keep the message payload small. Transfer ArrayBuffers when possible instead of cloning huge objects.

Profiling in Practice {#profiling}

Guessing is slower than measuring.

  1. Chrome Performance panel: look for long tasks (red triangles) and forced reflow.
  2. Coverage: find unused JavaScript after a typical user path.
  3. React Profiler: see which components re-render and why.
  4. Lighthouse / WebPageTest: catch payload and TBT regressions in CI.
export function mark(name) {
  performance.mark(name);
}

export function measure(name, start, end) {
  performance.measure(name, start, end);
  const entry = performance.getEntriesByName(name).at(-1);
  if (entry && process.env.NODE_ENV === 'production') {
    analytics.track('js_timing', {
      name,
      duration: Math.round(entry.duration),
    });
  }
}

Instrument checkout, search, and first product render. Those paths pay the bills.

Checklist {#checklist}

  • Keep tasks under 50ms on mid-range mobile
  • Cancel in-flight work on unmount and route change
  • Debounce input, throttle scroll, virtualize long lists
  • Split routes and defer non-critical vendors
  • Profile before and after; keep a budget on TBT and JS kilobytes

JavaScript performance is a product feature. Users feel it as "the site is snappy," even when they never mention JavaScript.