4 min read
On this page

Runtime Performance

Loading performance gets users to the page. Runtime performance keeps the experience smooth once they are there. Janky scrolling, unresponsive buttons, and growing memory usage all stem from runtime problems.

The Main Thread

JavaScript runs on the main thread along with rendering, layout, and painting. When JavaScript runs for too long, everything else waits.

Long Tasks

A long task is any task that takes more than 50ms. During a long task, the browser cannot respond to user input or paint new frames.

// This is a long task: it blocks the main thread for ~500ms
function processLargeArray(data) {
  return data.map((item) => expensiveTransform(item));
}

// Fix: break it into smaller tasks
async function processLargeArrayAsync(data) {
  const results = [];
  const chunkSize = 100;

  for (let i = 0; i < data.length; i += chunkSize) {
    const chunk = data.slice(i, i + chunkSize);
    results.push(...chunk.map((item) => expensiveTransform(item)));

    // Yield to the main thread
    await new Promise((resolve) => setTimeout(resolve, 0));
  }

  return results;
}

requestAnimationFrame

For smooth 60fps animations, use requestAnimationFrame (rAF). It syncs your code with the browser's paint cycle, running once per frame (~16.6ms at 60fps).

// Bad: setTimeout for animation
function animateBad(element) {
  let position = 0;
  setInterval(() => {
    position += 2;
    element.style.transform = `translateX(${position}px)`;
  }, 16); // Not synced with display refresh
}

// Good: requestAnimationFrame
function animateGood(element) {
  let position = 0;

  function step() {
    position += 2;
    element.style.transform = `translateX(${position}px)`;

    if (position < 500) {
      requestAnimationFrame(step);
    }
  }

  requestAnimationFrame(step);
}

When to Use CSS Instead

If the animation can be expressed in CSS, prefer CSS. The browser can optimize CSS animations on the compositor thread, avoiding main thread work entirely.

/* Compositor-friendly: transform and opacity */
.slide-in {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

/* Forces layout recalculation: avoid animating these */
.bad-animation {
  transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s;
}

Debouncing & Throttling

Events like scroll, resize, and input can fire dozens of times per second. Running expensive handlers on every event kills performance.

Debouncing

Wait until the user stops acting, then run once.

function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// Search only after the user stops typing for 300ms
const searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce((event) => {
  fetchSearchResults(event.target.value);
}, 300));

Throttling

Run at most once per interval, no matter how often the event fires.

function throttle(fn, limit) {
  let lastRun = 0;
  return function (...args) {
    const now = Date.now();
    if (now - lastRun >= limit) {
      lastRun = now;
      fn.apply(this, args);
    }
  };
}

// Update scroll position indicator at most every 100ms
window.addEventListener('scroll', throttle(() => {
  updateScrollIndicator();
}, 100));

When to Use Which

Technique Use Case
Debounce Search input, form validation, resize handler
Throttle Scroll position, mouse move tracking, drag events

Virtual Scrolling

Rendering 10,000 DOM nodes at once is slow. Virtual scrolling renders only the visible items, plus a small buffer.

class VirtualList {
  constructor(container, items, itemHeight) {
    this.container = container;
    this.items = items;
    this.itemHeight = itemHeight;
    this.visibleCount = Math.ceil(container.clientHeight / itemHeight);

    this.container.style.overflow = 'auto';
    this.content = document.createElement('div');
    this.content.style.height = `${items.length * itemHeight}px`;
    this.container.appendChild(this.content);

    this.container.addEventListener('scroll', () => this.render());
    this.render();
  }

  render() {
    const scrollTop = this.container.scrollTop;
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    const endIndex = Math.min(startIndex + this.visibleCount + 2, this.items.length);

    this.content.innerHTML = '';
    for (let i = startIndex; i < endIndex; i++) {
      const el = document.createElement('div');
      el.textContent = this.items[i];
      el.style.position = 'absolute';
      el.style.top = `${i * this.itemHeight}px`;
      el.style.height = `${this.itemHeight}px`;
      this.content.appendChild(el);
    }
  }
}

In practice, use a library like @tanstack/virtual or react-window. The concept is the same: render only what is visible.

Web Workers for CPU Work

Move heavy computation off the main thread entirely.

// main.js
const worker = new Worker('sort-worker.js');

worker.postMessage({ data: millionRecords });

worker.onmessage = (event) => {
  renderTable(event.data.sorted);
};
// sort-worker.js
self.onmessage = (event) => {
  const sorted = event.data.data.sort((a, b) => a.score - b.score);
  self.postMessage({ sorted });
};

Good Candidates for Workers

  • Sorting or filtering large datasets
  • Image processing (resizing, filters)
  • Data parsing (CSV, JSON of large files)
  • Compression / decompression
  • Cryptographic operations

Memory Leaks

Memory leaks happen when the application holds references to objects it no longer needs. The garbage collector cannot free them.

Detached DOM Nodes

// Leak: element removed from DOM but still referenced
let detachedElement = document.createElement('div');
document.body.appendChild(detachedElement);
document.body.removeChild(detachedElement);
// detachedElement still holds the node in memory

// Fix: remove the reference
detachedElement = null;

Forgotten Event Listeners

// Leak: listener keeps the handler (and its closure) alive
function setupHandler() {
  const largeData = new Array(1000000).fill('data');

  window.addEventListener('resize', () => {
    console.log(largeData.length); // Closure holds largeData
  });
}

// Fix: store a reference and remove when done
function setupHandlerFixed() {
  const largeData = new Array(1000000).fill('data');

  function handler() {
    console.log(largeData.length);
  }

  window.addEventListener('resize', handler);

  // Clean up when component unmounts
  return () => window.removeEventListener('resize', handler);
}

Timers

// Leak: interval runs forever, holding references
const intervalId = setInterval(() => {
  updateWidget(heavyData);
}, 1000);

// Fix: clear when no longer needed
clearInterval(intervalId);

Profiling with Chrome DevTools

Performance Tab

  1. Open DevTools > Performance
  2. Click Record
  3. Interact with the page (scroll, click, type)
  4. Stop recording
  5. Analyze the flame chart

What to Look For

  • Long tasks: yellow bars longer than 50ms in the main thread
  • Layout thrashing: purple layout events triggered repeatedly
  • Forced reflow: JavaScript reads layout properties after writes

Memory Tab

  1. Open DevTools > Memory
  2. Take a Heap Snapshot
  3. Interact with the page
  4. Take another snapshot
  5. Compare to find objects that should have been freed

Common Pitfalls

  • Animating layout properties: animating width, height, top, or left triggers layout recalculation every frame. Use transform and opacity instead.
  • Not cleaning up event listeners: listeners on window or document persist across component lifecycles in SPAs. Always remove them.
  • Using setInterval for animations: setInterval does not sync with the browser's paint cycle. Use requestAnimationFrame for visual updates.
  • Rendering the entire list: DOM nodes are expensive. Virtual scrolling can reduce thousands of nodes to dozens.
  • Ignoring memory until it crashes: memory leaks are silent. Profile periodically during development.
  • Forced synchronous layout: reading a layout property (offsetHeight, getBoundingClientRect) after modifying styles forces an immediate layout recalculation.

Key Takeaways

  • Long tasks (over 50ms) block the main thread and make the page feel unresponsive
  • Use requestAnimationFrame for visual updates and CSS transitions/transforms for compositor-friendly animations
  • Debounce for "wait until done" (search input) and throttle for "run at most N times per second" (scroll)
  • Virtual scrolling renders only visible items, turning a 10,000-node list into a 20-node list
  • Web Workers move CPU-intensive work off the main thread entirely
  • Memory leaks come from detached DOM nodes, forgotten event listeners, uncapped caches, and uncleared timers
  • Use Chrome DevTools Performance tab to find long tasks and the Memory tab to find leaks