Performance Optimization in JavaScript

Why should you care about Performance Optimization in JavaScript?

Performance optimization makes an application faster, more responsive, and more efficient. Learning to measure first helps you improve the parts users actually notice.

Performance optimization means making a web application faster, more responsive, and more efficient. Users should be able to see useful content, click controls, and complete tasks without unnecessary waiting.

Slow pages can frustrate users and increase the chance that they leave. Slow JavaScript can also make typing, scrolling, and animations feel unresponsive, even when the network connection is fast.

Performance problems usually come from a few places:

  • Doing too much work on the main thread (which blocks the browser from rendering)
  • Making too many or unnecessary DOM changes
  • Running expensive functions too frequently (on every scroll or keypress)
  • Keeping too many things in memory when you no longer need them
  • Loading too much JavaScript upfront before it is actually needed

Each section in this tutorial covers one of these problems - and shows you the technique to fix it. You do not need to apply every technique to every project. Just knowing they exist puts you ahead of most developers.

Measure before you optimize: Measure -> find the problem -> optimize -> measure again. Use DevTools and Lighthouse to find the real problem instead of changing code because a technique sounds useful.

Core Web Vitals give three useful signals: LCP is how quickly the main content appears, INP is how quickly the page responds to interactions, and CLS is how much the layout moves unexpectedly.

Good to know: Performance is also a topic interviewers love. Questions like "How would you optimise a slow search box?" or "What is debouncing?" are extremely common.

Do not start by adding an optimization because it sounds useful. Start with one symptom, measure it, make one change, and measure again. This example uses a page that feels slow when a button creates a large list.

  1. Reproduce: open the page, use the slow interaction three times, and note what feels slow.
  2. Record the interaction: open DevTools with F12, choose Performance, click Record, use the slow interaction once, then click Stop.
  3. Find the bottleneck: look for a long task, a large scripting block, repeated layout work, or an expensive function in the Main track. Do not guess from the total page score alone.
  4. Change one thing: batch DOM writes, reduce the work, or debounce the input that triggers it.
  5. Record again: use the same interaction and compare the before and after recordings under similar conditions.

Small timing check

performance.mark("list-start");
renderLargeList();
performance.mark("list-end");
performance.measure("render-large-list", "list-start", "list-end");

console.table(performance.getEntriesByName("render-large-list"));

The timing API gives you a number for one operation; the Performance panel shows the surrounding browser work. Use both when you need to connect a slow function to a visible delay.

Lighthouse walkthrough

Lighthouse is a report for a page load, while the Performance panel is a recording of an interaction. Run Lighthouse after you can reproduce the problem so its recommendations have context.

  1. Open the page in Chrome and open DevTools.
  2. Choose the Lighthouse tab. Select Performance, use Mobile for a realistic first check, and choose Navigation when you are measuring a page load.
  3. Click Analyze page load and wait for the report. Run it in a private window when browser extensions or other tabs could affect the result.
  4. Read the score as a signal, then inspect the opportunities and diagnostics. For example, a large image points toward responsive images or lazy loading; a long main-thread task points toward less JavaScript or better scheduling.
  5. Make one focused change, rerun Lighthouse, and compare the same device, page, and network conditions. Scores vary, so look for a consistent improvement across several runs.
Use the symptom to choose the first tool and next question.
Symptom First check Question to ask
Page feels slow while loading Lighthouse and Network Are images, scripts, or fonts larger or earlier than necessary?
Typing or scrolling feels laggy Performance recording Which handler or long task blocks the Main track?
Layout jumps while content loads Lighthouse CLS diagnostic Did media or injected content reserve its space?
Practical rule: A better score is useful only when the page also feels better. Keep the measurement conditions the same and record what changed.

JavaScript running in a typical page uses one main thread for the page's code, event handlers, and much of the rendering work. Browsers also use other threads, and Web Workers can run JavaScript separately, but work on the main thread can still block interaction and rendering.

When you run heavy synchronous code, you block that thread. While it is blocked, the browser cannot do anything else. Clicks do not work. The page freezes. Animations stop. This is called a blocking operation.

While the main thread is busy, the browser has less time to respond to input and update the page. Keep synchronous work short so the browser can return to those tasks regularly.

javascript

// BAD - blocks the main thread for several seconds
function heavyTask() {
  const start = Date.now();
  // Simulate heavy work - page freezes during this!
  while (Date.now() - start < 3000) {
    // spinning for 3 seconds...
  }
  console.log("Done");
}

heavyTask(); // Browser is frozen for 3 seconds

Use asynchronous APIs when you are waiting for a network request, file read, or timer. Promises, async/await, and setTimeout let other browser work continue while the result is pending. They do not move a CPU-heavy calculation to another thread. Reduce that work, split it into smaller pieces when appropriate, or use a Web Worker when it should run away from the main thread.

javascript

// BETTER - waiting for I/O does not block the main thread
async function fetchData(url) {
  const response = await fetch(url); // browser stays responsive
  const data     = await response.json();
  console.log(data);
}

fetchData("https://api.example.com/users");

// The browser can still respond to clicks and render frames
// while waiting for the network request to complete.

Whenever work involves waiting, like loading data, async APIs are a good fit. For CPU-heavy calculations, remember that the JavaScript still has to run somewhere, so reduce the work, break it up carefully, or move it to a Web Worker.

When you change the DOM, the browser may need to update the page. Too many or unnecessary changes can make the page slower, especially when they happen repeatedly in a loop.

Batching updates often helps because you prepare more work before changing the live page. The exact benefit depends on what changed and how the browser handles that update.

Diagram of an HTML document represented as a DOM tree

Batch DOM Updates

Instead of making many small DOM changes one by one, batch them all together and apply them at once.

Diagram comparing repeated DOM manipulation with a batched DOM update

javascript

// BAD - 1000 DOM writes can trigger repeated rendering work
const list = document.getElementById("myList");
for (let i = 0; i < 1000; i++) {
  const li = document.createElement("li");
  li.textContent = `Item ${i}`;
  list.appendChild(li); // repeated inserts can cause extra work
}

// GOOD - build everything first, then insert once
const list2 = document.getElementById("myList2");
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement("li");
  li.textContent = `Item ${i}`;
  fragment.appendChild(li); // prepare nodes outside the live DOM
}
list2.appendChild(fragment); // reduces repeated DOM insertion work

DocumentFragment lets you build several DOM nodes before adding them to the real page. It can reduce repeated insertion work, although the browser still decides how much page updating is needed afterward.

Cache DOM References

If you need the same DOM element many times, store it in a variable and reuse it. The bigger concern in large loops is often repeated DOM updates, not that every lookup is automatically expensive.

javascript

// BAD - looks up the element 1000 times
for (let i = 0; i < 1000; i++) {
  document.getElementById("output").textContent = i;
}

// GOOD - look it up once, reuse the reference
const output = document.getElementById("output");
for (let i = 0; i < 1000; i++) {
  output.textContent = i;
}

Store the DOM element in a variable first. Then use the variable. This is a small change that can make a big difference in tight loops.

Use Event Delegation

If you have 100 list items and want to handle clicks on each one, you could add 100 event listeners. Instead, use event delegation - add one listener to the parent and let events bubble up. This reduces the number of listeners you create and works especially well for dynamic lists or lots of child elements.

Diagram showing a parent element handling clicks from child elements with event delegation

javascript

// BAD - 100 separate listeners
document.querySelectorAll("li").forEach(li => {
  li.addEventListener("click", (e) => {
    console.log("Clicked:", e.target.textContent);
  });
});

// GOOD - one listener on the parent (event delegation)
document.getElementById("myList").addEventListener("click", (e) => {
  if (e.target.tagName === "LI") {
    console.log("Clicked:", e.target.textContent);
  }
});

The click on a <li> bubbles up to the parent <ul>. You catch it there, check what was clicked, and handle it. One listener does the job of many, and newly added list items can work without attaching new listeners to each one.

Events such as scroll, resize, and input can fire often. If their handlers do expensive work every time, the page may spend too much time responding to events instead of updating the interface.

Two techniques solve this: debounce and throttle.

Debounce

Debounce delays the function call until the user has stopped doing something for a set amount of time. Consider a search box - you do not want to fire an API request on every single keystroke. You want to wait until the user has finished typing, then fire once.

javascript

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

function searchAPI(query) {
  console.log("Searching for:", query);
  // ... API call here
}

const debouncedSearch = debounce(searchAPI, 300);

// User types fast - searchAPI is only called once,
// 300ms after the last keystroke
document.getElementById("search").addEventListener("input", (e) => {
  debouncedSearch(e.target.value);
});

Without debounce, typing "hello" would fire the API 5 times. With debounce (300ms), it fires only once - 300ms after the user stops typing. Much better.

Throttle

Throttle limits how often a function can run - no matter how frequently the event fires. Take a scroll handler that updates a sticky header. You do not need to run it 60 times per second. Running it once every 200ms is more than enough.

javascript

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

function updateHeader() {
  console.log("Scroll position:", window.scrollY);
}

const throttledUpdate = throttle(updateHeader, 200);

// Even if scroll fires 60 times/second, updateHeader
// only runs at most 5 times/second (every 200ms)
window.addEventListener("scroll", throttledUpdate);

Use requestAnimationFrame() for Visual Updates

requestAnimationFrame() is useful when you are updating something visual, like moving an element or animating a progress bar. It lets the browser schedule your code right before the next repaint, which is usually a better fit than a timer for visual work.

javascript

const box = document.getElementById("box");
let position = 0;

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

  if (position < 300) {
    requestAnimationFrame(animateBox);
  }
}

requestAnimationFrame(animateBox);
Technique When to use How it works
debounce Search input, form validation, resize handler Waits until the user stops, then fires once
throttle Scroll events, mouse move, window resize Fires at most once per time interval, no matter how many events

Memoization stores the result of a function. When the same input is used again, the saved result can be returned instead of calculating it again.

Memoization is most useful when a calculation is expensive, the same inputs occur repeatedly, and the function returns the same result for the same input. It also uses memory, so it is not automatically helpful for every function.

javascript

// WITHOUT memoization - recalculates every time
function slowSquare(n) {
  // Imagine this is very expensive
  console.log(`Calculating ${n} -> ${n * n}`);
  return n * n;
}

console.log(slowSquare(5)); // Calculating 5 -> 25
console.log(slowSquare(5)); // Calculating 5 -> 25 (repeated work)
console.log(slowSquare(5)); // Calculating 5 -> 25 (repeated work again)

javascript

// WITH memoization - calculates once, then uses cache
function memoize(fn) {
  const cache = new Map();
  return function(n) {
    if (cache.has(n)) {
      console.log(`Cache hit for ${n}`);
      return cache.get(n);
    }
    console.log(`Calculating ${n} -> ${n * n}`);
    const result = fn(n);
    cache.set(n, result);
    return result;
  };
}

function square(n) { return n * n; }

const fastSquare = memoize(square);

console.log(fastSquare(5)); // Calculating 5 -> 25
console.log(fastSquare(5)); // Cache hit for 5 -> 25
console.log(fastSquare(5)); // Cache hit for 5 -> 25

The memoize wrapper stores results in a Map cache. If the same input appears again, the cached result is returned immediately. Using has() and get() also means cached values like undefined are handled correctly. This is particularly useful for expensive, deterministic computations like recursion or complex calculations. API results need an expiration or invalidation strategy because the data can change.

A real-world example you may already know is React.memo. It helps avoid unnecessary component renders when props have not changed. That is related to performance, but it is not the same as generic function-result memoization.

Lazy loading means only loading something when it is actually needed. Instead of loading everything upfront when the page opens, you load things on demand - when the user scrolls to them, clicks something, or navigates to a section.

Lazy loading can improve initial loading when a resource is not needed immediately, but it is not the right choice for every resource. Important above-the-fold content may need to load early.

Lazy Loading Images

HTML has a built-in attribute for lazy loading images. Just add loading="lazy" to any <img> tag and the browser will not fetch the image until it is about to scroll into view.

html

<!-- Without lazy loading - loads immediately even if off-screen -->
<img src="big-photo.jpg" alt="Photo">

<!-- With lazy loading - only loads when near the viewport -->
<img src="big-photo.jpg" alt="Photo" loading="lazy">

Lazy Loading JavaScript Modules

You can also lazy-load entire JavaScript modules using dynamic import(). Instead of loading a module at the top of your file (which adds to page load time), you load it only when a user actually triggers that feature.

javascript

// Static import - loads the module when the page loads (always)
import { heavyChart } from './chart-library.js';

// Dynamic import - loads the module only when the user clicks
document.getElementById("showChart").addEventListener("click", async () => {
  const { heavyChart } = await import('./chart-library.js');
  heavyChart.render("#canvas");
});

If a user never opens the chart, there is no need to download its code during the first page load. Measure the result, because a delayed request also adds work when the feature is eventually opened.

Network performance matters too. Keep bundle sizes small, enable compression, cache static assets, use a CDN when it makes sense, avoid unnecessary requests, and use code splitting so users download less JavaScript up front.

Your browser allocates memory to store variables, functions, objects, and DOM elements. When you are done with something, JavaScript's garbage collector should automatically free that memory.

But sometimes, your code accidentally holds onto references it no longer needs. The garbage collector sees those references and thinks the data is still being used - so it never frees the memory. This is called a memory leak.

A leak can make an application use more memory over time and may eventually hurt responsiveness. The effect depends on how much memory is retained and how long the page stays open.

Common Memory Leaks

1. Event listeners that are never removed:

javascript

// BAD - adds a new listener every time the function runs
function setup() {
  document.getElementById("btn").addEventListener("click", handleClick);
}

// Calling setup() repeatedly adds more listeners. Remove them when this
// feature or component is no longer needed.

// GOOD - remove the listener when you are done with it
function setup() {
  const btn = document.getElementById("btn");
  btn.addEventListener("click", handleClick);
  // Later, when the component unmounts:
  btn.removeEventListener("click", handleClick);
}

2. Global variables that grow unbounded:

javascript

// BAD - logs array grows forever, never cleared
const logs = [];
function logEvent(event) {
  logs.push(event); // grows without limit
}

// GOOD - limit the size or clear when no longer needed
const logs = [];
const MAX_LOGS = 100;
function logEvent(event) {
  logs.push(event);
  if (logs.length > MAX_LOGS) {
    logs.shift(); // remove oldest entry
  }
}

Use WeakMap and WeakSet for Temporary Data

If you need to associate extra data with an object temporarily, use WeakMap instead of Map. A WeakMap holds weak references - when the object is no longer used anywhere else, the entry is automatically removed from the WeakMap. No memory leak.

Diagram comparing Map and WeakMap object references in JavaScript

javascript

// WeakMap - automatically cleaned up when the key object is gone
const cache = new WeakMap();

function processElement(el) {
  if (cache.has(el)) {
    return cache.get(el); // return cached result
  }
  const result = el.textContent.trim();
  cache.set(el, result);  // store only while el is alive
  return result;
}

// When the DOM element is removed, the WeakMap entry disappears too.
// No manual cleanup needed.

WeakMap is useful when you want to associate data with an object without keeping that object alive just because it is in the map. It can help with certain memory-retention problems, but it does not prevent every kind of memory leak.

  1. Describe the slow page or interaction and reproduce it consistently.
  2. Measure it with DevTools, Lighthouse, the Network panel, or the User Timing API.
  3. Find the largest contributor before changing code.
  4. Make one focused change, such as reducing work, batching DOM updates, or loading a resource later.
  5. Measure again under similar conditions and keep the change only if it improves the relevant result.
  6. Check slower devices and connections, not only your development machine.
  7. Remove listeners, timers, and references when a feature is no longer needed.
  • Avoid blocking the main thread. Use async APIs for waiting on I/O, but remember that CPU-heavy JavaScript still runs on the thread executing it. Use Web Workers when heavy work needs another thread.
  • Batch DOM updates. Use DocumentFragment to reduce repeated DOM insertion work, and remember that DOM changes can trigger style recalculation, layout, paint, or compositing depending on what changed.
  • Cache DOM references. Look up elements once, store in a variable, then reuse. Repeated querySelector calls inside loops are wasteful.
  • Use event delegation. One listener on a parent can handle clicks from many child elements, which reduces listeners and works well for dynamic lists.
  • Debounce rapid-fire events like search inputs - wait until the user pauses, then fire once. Throttle continuous events like scroll - fire at most once per interval.
  • Use requestAnimationFrame() for visual updates so the browser can schedule the work before the next repaint.
  • Memoize expensive functions. Cache results by input so identical calls skip the calculation entirely, and remember that React.memo is specifically about avoiding unnecessary component renders.
  • Lazy load images and JavaScript modules. Only load what the user actually needs, when they need it.
  • Measure first. Follow this loop: Measure -> find the bottleneck -> optimize -> measure again, using DevTools or Lighthouse.
  • Watch web and network performance. Keep an eye on Core Web Vitals like LCP, INP, and CLS, and reduce network cost with smaller bundles, compression, caching, CDNs, fewer requests, and code splitting.
  • Avoid memory leaks. Remove event listeners when done. Cap growing arrays. Use WeakMap for temporary object-linked data.
What's next? Explore Design Patterns to learn reusable code strategies, or check out Memory Management for a deeper dive into how JavaScript handles memory.

Reviewed by

SimplyJavaScript Editorial Team

Technical editors and JavaScript educators with hands-on experience building frontend projects, writing learning material, and reviewing tutorials for clarity, accuracy, and beginner-friendly guidance.