Basic JavaScript Benchmarking

Looking to optimize your code? You'll need to test it.

By testing different approaches, you can compare them fairly and choose the most performant option.

You don't need any special tools to do this - your browser console can compare the speed of individual functions easily!

A Reusable Benchmark Utility Function

I wrote a utility to make it easy for you to test the performance of your functions. After several iterations and improvements, this is the code:

const benchmark = ({
  runCount = 10_000_000,
  getArgs = () => [],
  testFunctions = {},
}) => {
  const test = (fn) => {
    const start = performance.now();
    for (let i = 0; i < runCount; i++) {
      const args = getArgs();
      fn(...args);
    }
    const end = performance.now();
    const runtime = end - start;
    return runtime;
  };

  const results = {};
  for (const funcName in testFunctions) {
    const fn = testFunctions[funcName];
    results[funcName] = test(fn);
  }
  return results;
};

To use this utility, you'll need to provide the functions you want to test in the testFunctions object.

Also, provide an optional getArgs function to generate the arguments for you tests. You can have getArgs return the same value for each test or random arguments. I prefer to randomize the inputs to make sure the JavaScript engine isn't somehow optimizing the code based on a static and predictable argument, because I know the arguments will not be static or predictable in production!

Testing Performance in Practice

Here's how to use it:

// functions to test
const getHighestNumber1 = (numberArray) => {
  let highest = numberArray[0];
  for (let i = 1; i < numberArray.length; i++) {
    if (numberArray[i] > highest) {
      highest = numberArray[i];
    }
  }
  return highest;
};

const getHighestNumber2 = (numberArray) => {
  let highest = numberArray[0];
  for (let i = 1; i < numberArray.length; i++) {
    let num = numberArray[i];
    if (num > highest) {
      highest = num;
    }
  }
  return highest;
};

const getHighestNumber3 = (numberArray) => {
  let highest = numberArray[0];
  numberArray.forEach(num => {
    if (num > highest) {
      highest = num;
    }
  });
  return highest;
};

const getHighestNumber4 = (numberArray) => {
  return Math.max(...numberArray);
};

// benchmark the functions!
const results = benchmark({
  runCount: 10_000_000,
  getArgs: () => {
    const numberArray = [];
    for (let i = 0; i < 10; i++) {
      numberArray.push(Math.random() * 100);
    }
    return [numberArray];
  },
  testFunctions: {
    getHighestNumber1,
    getHighestNumber2,
    getHighestNumber3,
    getHighestNumber4,
  },
});

I suggest running the test several times in your browser console to be sure of the results.

After running the tests above, it is clear that getHighestNumber1 and getHighestNumber2 are roughly equal in performance, while getHighestNumber3 is just a bit slower. Looks like that Array.prototype.forEach call comes at a cost!

Finally, we see that getHighestNumber4 lags behind significantly, taking 2x to 3x as long to execute as the other options. Even though Math.max is built into JavaScript, it has worse performance than writing your own for loop and if statement!

A Word of Caution

Although the example results above make Math.max look pretty bad, I wouldn't stop using Math.max. Speaking from experience, it's usually more important to be able to read your code easily than it is to squeeze every last millisecond of performance out of it.

A for loop and if statement might be a tad faster, but Math.max is far more concise and make your intent immediately clear. Before you try to optimize all of your code, remember that network calls (to APIs, databases, etc.) take far more time than a local function call. Be sure you are optimizing where it matters, and only when it matters!

Other Considerations

In my utility function, I called getArgs within the innermost for loop, which also calls the function to be tested. The advantage of this is that each time the test function is called, it will have completely new arguments. This can help prevent the JavaScript engine from optimizing the code in a way that doesn't represent a production environment.

However, one disadvantage of calling getArgs at that location is that it makes the test slower. The call time of getArgs ends up as part of the test... for each iteration. Since getArgs is the same for each of your test functions, and since it is called the same number of times, this is still a fair approach. But it is slower.

Below, I labelled two alternative locations for calling getArgs.

const benchmark = ({
  runCount = 10_000_000,
  getArgs = () => [],
  testFunctions = {},
}) => {
  const args = getArgs(); // Alternative 1

  const test = (fn) => {
    const args = getArgs(); // Alternative 2
    const start = performance.now();
    for (let i = 0; i < runCount; i++) {
      const args = getArgs(); // Current Location
      fn(...args);
    }
    const end = performance.now();
    const runtime = end - start;
    return runtime;
  };

  const results = {};
  for (const funcName in testFunctions) {
    const fn = testFunctions[funcName];
    results[funcName] = test(fn);
  }
  return results;
};

Alternative 1

The first alternative is to call getArgs outside of the test function. By doing this, all of your tests will use the same set of arguments. However, this increases the chances that the JavaScript engine will perform optimizations that skew your test results. For example, it may realize that the arguments are constant and that the function is deterministic, and therefore cache the function return value. Also, this means your test functions don't have a chance to actually run against a variety of arguments, which would better represent their performance in a production setting.

Alternative 2

The second alternative is to call getArgs right before starting the timer for each function. This might decrease the chances of the engine performing its own unrealistic optimizations, but it still doesn't provide a variety of inputs for your functions to represent the unpredictable nature of a production environment.

My Pick

Overall, I prefer the current location of getArgs and I think it helps ensure the test results are representative of which function will have better performance in production.