How to measure performance in JavaScript

How to measure performance in JavaScript

When we talk about JavaScript performance, the first thing to grasp is that performance isn’t just about speed; it’s also about responsiveness. You can have a fast algorithm that still feels sluggish if it blocks the main thread. Understanding the metrics can help you identify bottlenecks and areas for improvement.

One of the primary metrics you should focus on is Time to First Byte (TTFB). This measures how long it takes for the browser to receive the first byte of data after a user makes a request. A slow TTFB can lead to a poor user experience, especially if your application relies heavily on server-side data.

Another important metric is First Contentful Paint (FCP). This tells you when the browser first renders any content from the DOM, allowing users to perceive that something is happening. Below is how you can capture FCP using the Performance API:

performance.mark('start');
document.addEventListener('DOMContentLoaded', () => {
  performance.mark('fcp');
  performance.measure('FCP', 'start', 'fcp');
});

Then there’s Largest Contentful Paint (LCP), which tracks when the largest piece of content in the viewport is rendered. This is crucial for determining perceived load speed. You can measure LCP like this:

let lcp;
const observer = new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  lcp = entries[entries.length - 1];
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });

Don’t forget about First Input Delay (FID), which measures the time from when a user first interacts with your page (like clicking a link) to the time when the browser starts processing that interaction. High FID values can indicate that your JavaScript is blocking interactions. Here’s a simple setup to measure FID:

let fid;
const observer = new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  fid = entries[entries.length - 1];
});
observer.observe({ type: 'first-input', buffered: true });

All these metrics give you a clearer picture of how your web application performs in real-world scenarios. But they’re not just numbers; they inform the decisions you make about optimization. You might find that a certain feature is taking too long to load and impacts your LCP, or that blocking scripts cause a high FID.

Understanding how to leverage these metrics effectively can guide your optimization strategies. You’ll often find that improving one metric can positively influence others, like reducing JavaScript bundle size can lead to better FCP and LCP. However, it’s crucial to measure and iterate, as different changes may have unforeseen effects on your application’s overall performance.

Finally, remember that these metrics often have differing relevance depending on the context of your application. A web app might prioritize quick FCP for immediate user feedback, while a content-heavy site could be more concerned with LCP. Aligning your performance measurements with user experience goals is vital for delivering a smooth and engaging experience.

The role of the browser in performance measurement

The browser plays a critical role in performance measurement by providing tools and APIs that help developers gather data about how their applications behave in the real world. Browsers are not just interpreters of HTML, CSS, and JavaScript; they also have sophisticated engines and performance monitoring capabilities that can significantly aid in profiling and optimizing web applications.

One of the most powerful tools at your disposal is the Performance API. This API allows you to access detailed timing information about different phases of page loading, user interaction, and resource fetching. By leveraging this API, you can gain insights into not just how fast your application is, but also where delays are occurring. For instance, using the performance.getEntriesByType('resource') method, you can analyze how long resources take to load:

const resources = performance.getEntriesByType('resource');
resources.forEach((resource) => {
  console.log(${resource.name} took ${resource.duration} ms to load.);
});

Another important feature is the Navigation Timing API, which provides timing data related to how long each part of the navigation process takes. This can help you diagnose issues with redirects and server response times. Here’s how you can extract navigation timing data:

const navTiming = performance.getEntriesByType('navigation')[0];
console.log(Time to First Byte: ${navTiming.responseStart - navTiming.requestStart} ms);
console.log(DOMContentLoaded: ${navTiming.domContentLoadedEventEnd - navTiming.requestStart} ms);

Moreover, the Long Task API allows you to identify tasks that block the main thread for long durations, which can lead to a poor user experience. By observing long tasks, you can understand when and where your application might be unresponsive:

const longTaskObserver = new PerformanceObserver((entryList) => {
  entryList.getEntries().forEach((entry) => {
    console.log(Long task detected: ${entry.duration} ms);
  });
});
longTaskObserver.observe({ type: 'longtask', buffered: true });

Browsers also provide built-in developer tools that allow you to visualize performance metrics in a more user-friendly way. The Performance tab in Chrome DevTools, for instance, enables you to record and analyze the runtime performance of your application. You can see frames per second (FPS), CPU usage, and how different tasks are distributed over time. This can help you pinpoint exactly what part of your code needs optimization.

Utilizing these browser capabilities enables you to focus on areas that have the most significant impact on user experience. For example, if you notice that JavaScript execution is taking longer than expected, you might consider techniques like code splitting or deferring non-essential scripts. Here’s how you can implement script deferring:


Deferring scripts ensures that they execute only after the HTML document has been fully parsed, which can greatly enhance perceived performance. Additionally, consider using async for scripts that are independent and can be loaded without blocking rendering:


By understanding the role of the browser in performance measurement, you can make informed decisions about how to enhance your web application’s responsiveness and efficiency. This understanding directly influences the optimization techniques you choose to implement, ensuring that you’re not just making arbitrary changes but rather targeted improvements based on solid data. As you dive deeper into performance profiling, always keep in mind the various dimensions of browser performance tools, and don’t hesitate to experiment with different strategies to find what works best for your specific application context.

Tools and techniques for effective performance profiling

When it comes to profiling, the Chrome DevTools Performance tab is your best friend. It’s a powerful, if somewhat intimidating, tool that gives you a second-by-second breakdown of everything the browser is doing. You can record a user interaction, a page load, or any other scenario, and the tool will generate a detailed report, including a flame chart that visualizes execution time.

To get started, you open DevTools, go to the Performance tab, and hit the record button. Perform the action you want to profile, then stop the recording. The first things to look for are the red triangles in the summary strip, which indicate “long tasks” that block the main thread for more than 50 milliseconds. You should also watch out for frequent, forced synchronous layouts, often called “layout thrashing,” which can be a major performance killer. This happens when your JavaScript repeatedly writes and then reads from the DOM, forcing the browser to recalculate layout information over and over.

The main part of the report is the flame chart. This chart shows you call stacks over time. The horizontal axis is time, and the vertical axis is the call stack. A wide bar means a function took a long time to execute. If you see a function with a lot of other function calls stacked on top of it, that’s normal. But if you see a wide bar with very little stacked on top, it means that specific function is doing a lot of heavy work itself. This is what we call a “hot spot.”

For example, imagine you have a function that processes a large amount of data. It might look something like this:

function calculateHeavyStuff() {
  let sum = 0;
  for (let i = 0; i  {
  calculateHeavyStuff();
});

If you profile a click on myButton, the flame chart will show a very wide block corresponding to the calculateHeavyStuff function. This immediately tells you that this function is your bottleneck. The solution isn’t always obvious-maybe you can break the work into smaller chunks using setTimeout or a Web Worker-but the profiler has done its job: it has pointed you exactly where to look.

Performance isn’t just about CPU time; it’s also about memory. A memory leak, where your application continuously consumes more memory without releasing it, will eventually slow down the page and can even cause the browser tab to crash. The Chrome DevTools Memory tab is the tool for this job. It lets you take heap snapshots, which are point-in-time pictures of all the objects your application has in memory.

The classic technique for finding memory leaks is to take a heap snapshot, perform an action that you suspect is causing a leak, perform it again, and then take a second snapshot. After that, you compare the two snapshots. The Memory tool can show you which objects were allocated between the two snapshots and are still present in memory. If you see objects piling up that should have been garbage collected, you’ve found your leak.

A common source of leaks is event listeners that are never removed, which keep references to DOM elements that have been removed from the page. This prevents the garbage collector from cleaning them up.

function createLeakyComponent() {
  const element = document.createElement('div');
  document.body.appendChild(element);

  // This large array is referenced by the listener's closure
  const largeData = new Array(100000).fill('leak');

  const onResize = () => {
    // This function keeps largeData and element alive
    console.log(element.offsetWidth, largeData.length);
  };

  window.addEventListener('resize', onResize);

  // We provide a way to remove the element, but not the listener
  element.addEventListener('click', () => {
    document.body.removeChild(element);
    // ERROR: The 'resize' listener on window is never removed.
    // It still holds a reference to element and largeData.
  });
}

In the example above, every time you click the div, it gets removed from the DOM, but the resize event listener on the window object remains. This listener holds a reference to the detached element and the largeData array, preventing them from being garbage collected. By comparing heap snapshots before and after clicking the element, you would see detached DOM tree nodes and the large array persisting in memory.

Beyond DevTools, other tools offer different perspectives. Lighthouse is an automated tool that runs a series of audits against your page and provides a report with scores for performance, accessibility, and more. It’s great for a high-level overview and for catching common mistakes. For more in-depth network and rendering analysis, WebPageTest is invaluable. It allows you to test your site from various locations around the world, on real devices, and with different network conditions, giving you a much more realistic picture of what your users are experiencing.

Optimizing performance based on measurement results

So you’ve run the profiler, you’ve stared at the flame charts, and you’ve got a list of performance metrics that look more like a cry for help than a report card. What now? The whole point of measurement is to guide your optimization efforts. Don’t just start refactoring code randomly because you read a blog post about a “faster” way to loop through an array. Your profiling data is your treasure map; the red flags and long bars are marking the spot where the treasure-or in this case, the performance bottleneck-is buried.

Let’s go back to that calculateHeavyStuff function that was blocking the main thread. The flame chart showed it as one giant, uninterrupted block of work. The user clicks a button, and the whole UI freezes until the calculation is done. This is a classic cause of high First Input Delay (FID). The fix is to break the work into smaller chunks and yield to the main thread between chunks, allowing the browser to handle other things, like rendering updates or responding to user input. Using setTimeout with a delay of 0 is a classic, if crude, way to do this.

function calculateHeavyStuffChunked(i = 0, sum = 0) {
  const chunkSize = 100000;
  const limit = Math.min(i + chunkSize, 10000000);

  for (let j = i; j < limit; j++) {
    sum += Math.sqrt(j) * Math.sin(j);
  }

  if (limit  calculateHeavyStuffChunked(limit, sum), 0);
  } else {
    console.log('Calculation finished:', sum);
  }
}

document.getElementById('myButton').addEventListener('click', () => {
  console.log('Calculation started...');
  calculateHeavyStuffChunked();
});

Now, let’s fix that memory leak from the createLeakyComponent example. The problem was an event listener on the window object that was never removed. It held a reference to a DOM element that was no longer in the document, preventing it from being garbage collected. The solution is simple but crucial: when you’re done with an event listener, you must remove it. This is especially important in Single Page Applications where components are constantly being mounted and unmounted.

function createCleanComponent() {
  const element = document.createElement('div');
  document.body.appendChild(element);

  const largeData = new Array(100000).fill('no-leak');

  const onResize = () => {
    console.log(element.offsetWidth, largeData.length);
  };

  window.addEventListener('resize', onResize);

  element.addEventListener('click', () => {
    document.body.removeChild(element);
    // The fix: remove the listener when it's no longer needed.
    window.removeEventListener('resize', onResize);
  });
}

Another insidious performance killer is layout thrashing. This happens when your code gets stuck in a loop of reading from the DOM and then writing to it. Every time you read a property like offsetHeight or offsetLeft after a DOM modification, you force the browser to perform a synchronous layout calculation to give you the up-to-date value. Doing this repeatedly in a loop is a recipe for a janky, unresponsive UI. Here’s a bad example:

// BAD: Causes layout thrashing
function sizeElementsBadly(elements) {
  for (let i = 0; i < elements.length; i++) {
    // Read offsetWidth, which might trigger a layout
    const width = document.body.offsetWidth;
    // Write to the element's style, which invalidates the layout
    elements[i].style.width = (width / (i + 2)) + 'px';
  }
}

The fix is to batch your reads and writes. First, read all the values you need from the DOM. Then, perform all your write operations. This allows the browser to perform a single layout calculation after all the changes have been made, instead of one for each iteration of the loop.

// GOOD: Avoids layout thrashing by batching reads and writes
function sizeElementsWell(elements) {
  // 1. Read the value once
  const width = document.body.offsetWidth;

  // 2. Write all the changes
  for (let i = 0; i < elements.length; i++) {
    elements[i].style.width = (width / (i + 2)) + 'px';
  }
}

Finally, if your measurements from the Network panel or the Performance API show that your initial JavaScript bundle is huge, leading to a poor FCP or LCP, the answer is code splitting. Instead of shipping one monolithic bundle.js file, you can break it up into smaller chunks that are loaded on demand. Modern bundlers like Webpack or Vite make this straightforward using dynamic import() syntax.

// Instead of a static import:
// import { showDatePicker } from './date-picker-library';

document.getElementById('show-picker-btn').addEventListener('click', () => {
  // Dynamically import the code only when it's needed
  import('./date-picker-library').then(module => {
    module.showDatePicker();
  }).catch(err => {
    console.error('Failed to load date picker', err);
  });
});

This approach dramatically reduces the amount of JavaScript the browser has to download, parse, and execute for the initial page load. The user gets to a usable state much faster, and the more complex, less-used features are loaded in the background when the user actually asks for them. This is a direct, measurable way to improve those core web vital metrics based on the data you’ve gathered. The process is cyclical: measure, identify the biggest bottleneck, fix it, and then measure again.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *