DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

JavaScript Performance: Profiling with Firefox, Fixing Layout Thrash, and Virtual Lists

JavaScript performance optimization follows the rule "measure first, then optimize." Firefox's Performance tab gives you a precise flame graph of every millisecond spent in your code. This guide covers the common bottlenecks and how to fix them.

Measurement First

// Performance API: precise timing
const start = performance.now();
expensiveOperation();
console.log(`Took ${performance.now() - start}ms`);

// Mark and measure
performance.mark('operation-start');
expensiveOperation();
performance.mark('operation-end');
performance.measure('operation', 'operation-start', 'operation-end');
const [entry] = performance.getEntriesByName('operation');
console.log(entry.duration, 'ms');
Enter fullscreen mode Exit fullscreen mode

Firefox Performance Tab

  1. Open DevTools (F12) → Performance
  2. Click Record (or Ctrl+Shift+E)
  3. Perform the slow operation
  4. Click Stop
  5. Analyze the flame graph

What to look for:

  • Long tasks (shown in red): tasks > 50ms block the main thread
  • Forced reflows: JavaScript reads layout properties after DOM mutations
  • Render blocking: long paint or layout calculations
  • GC pauses: frequent garbage collection

The Bottlenecks

Forced Layout (Thrashing)

// BAD: forces layout calculation between each read/write
for (const el of elements) {
  el.style.width = el.offsetWidth + 10 + 'px'; // Read then write — forces reflow
}

// GOOD: batch reads, then writes
const widths = elements.map(el => el.offsetWidth); // Read all
elements.forEach((el, i) => el.style.width = widths[i] + 10 + 'px'); // Write all
Enter fullscreen mode Exit fullscreen mode

Properties that trigger layout: offsetWidth, offsetHeight, getBoundingClientRect(), scrollTop, clientWidth, getComputedStyle().

DOM Manipulation

// BAD: each append forces layout recalculation
const list = document.querySelector('ul');
for (const item of data) {
  const li = document.createElement('li');
  li.textContent = item;
  list.appendChild(li); // Triggers layout each time
}

// GOOD: DocumentFragment or innerHTML
const fragment = document.createDocumentFragment();
for (const item of data) {
  const li = document.createElement('li');
  li.textContent = item;
  fragment.appendChild(li);
}
list.appendChild(fragment); // Single DOM operation

// Even faster for static content: innerHTML (no individual element creation)
list.innerHTML = data.map(item => `<li>${escapeHtml(item)}</li>`).join('');
Enter fullscreen mode Exit fullscreen mode

Long Synchronous Operations

// BAD: sorts a huge array on the main thread, blocking UI
function sortLargeArray(arr) {
  return arr.sort((a, b) => a - b); // Blocks for seconds on large arrays
}

// GOOD: offload to a Web Worker
const worker = new Worker(URL.createObjectURL(new Blob([`
  self.onmessage = (e) => {
    const sorted = e.data.sort((a, b) => a - b);
    self.postMessage(sorted);
  };
`], { type: 'text/javascript' })));

async function sortLargeArray(arr) {
  return new Promise((resolve) => {
    worker.postMessage(arr, [arr.buffer]); // Transfer, don't copy
    worker.onmessage = (e) => resolve(e.data);
  });
}
Enter fullscreen mode Exit fullscreen mode

Memory Leaks

// Common leak: event listener not removed
class Component {
  constructor() {
    // Store reference for cleanup
    this.handleClick = this.handleClick.bind(this);
    document.addEventListener('click', this.handleClick);
  }

  destroy() {
    document.removeEventListener('click', this.handleClick); // Cleanup!
  }
}

// Common leak: closure holds reference
function createLeak() {
  const bigData = new Array(1000000).fill('data');

  return {
    doSomething: () => {
      // bigData is referenced here — never GC'd as long as this object exists
      console.log(bigData[0]);
    }
  };
}

// Fix: only keep what you need
function noLeak() {
  const firstItem = new Array(1000000).fill('data')[0]; // Only keep first item

  return {
    doSomething: () => console.log(firstItem)
  };
}
Enter fullscreen mode Exit fullscreen mode

Debounce and Throttle

// Debounce: execute after N ms of no calls (search input, resize)
function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// Throttle: execute at most once per N ms (scroll handlers, mouse moves)
function throttle(fn, interval) {
  let lastCall = 0;
  return function(...args) {
    const now = Date.now();
    if (now - lastCall >= interval) {
      lastCall = now;
      return fn.apply(this, args);
    }
  };
}

// Usage
const searchInput = document.querySelector('#search');
searchInput.addEventListener('input', debounce(async (e) => {
  const results = await search(e.target.value);
  display(results);
}, 300));

window.addEventListener('scroll', throttle(updateScrollProgress, 100));
Enter fullscreen mode Exit fullscreen mode

Virtualization for Long Lists

Rendering thousands of DOM nodes is slow. Only render what's visible:

class VirtualList {
  constructor(container, items, itemHeight) {
    this.container = container;
    this.items = items;
    this.itemHeight = itemHeight;
    this.render();
    this.container.addEventListener('scroll', () => this.render());
  }

  render() {
    const scrollTop = this.container.scrollTop;
    const containerHeight = this.container.clientHeight;

    const startIndex = Math.floor(scrollTop / this.itemHeight);
    const endIndex = Math.min(
      startIndex + Math.ceil(containerHeight / this.itemHeight) + 1,
      this.items.length
    );

    this.container.innerHTML = `
      <div style="height: ${this.items.length * this.itemHeight}px; position: relative;">
        ${this.items.slice(startIndex, endIndex).map((item, i) => `
          <div style="position: absolute; top: ${(startIndex + i) * this.itemHeight}px; height: ${this.itemHeight}px;">
            ${item}
          </div>
        `).join('')}
      </div>
    `;
  }
}
Enter fullscreen mode Exit fullscreen mode

Production implementations use IntersectionObserver and more sophisticated recycling. Libraries: react-virtual, vue-virtual-scroller, @tanstack/virtual.

requestIdleCallback for Non-Urgent Work

// Schedule work during browser idle periods
function processInBackground(items, processor) {
  let index = 0;

  function doWork(deadline) {
    while (index < items.length && deadline.timeRemaining() > 0) {
      processor(items[index++]);
    }

    if (index < items.length) {
      requestIdleCallback(doWork); // More work remaining
    }
  }

  requestIdleCallback(doWork);
}

// Analytics, cleanup, prefetching — things that shouldn't block the user
processInBackground(analyticsEvents, sendToAnalytics);
Enter fullscreen mode Exit fullscreen mode

Memoization

Cache expensive function results:

function memoize(fn) {
  const cache = new Map();
  return function(...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const expensiveCalc = memoize((n) => {
  // Expensive calculation...
  return result;
});

expensiveCalc(42); // Computed
expensiveCalc(42); // Returned from cache instantly
Enter fullscreen mode Exit fullscreen mode

Using Firefox to Find the Actual Bottleneck

Profile your code in Firefox before optimizing:

  1. Record a performance trace during the slow operation
  2. Look at the bottom-up view to find functions consuming the most time
  3. Check for "Forced Reflow" markers in the timeline
  4. Look for GC (garbage collection) pauses in purple

Only optimize what the profiler shows is slow. Premature optimization is the root of much wasted engineering time.

Top comments (0)