Optimizing JavaScript Performance: Focus on Function Usage

mochafreddo

Geoffrey Kim

Posted on May 28, 2023

Optimizing JavaScript Performance: Focus on Function Usage

While having a group study, we stumbled upon the question, "Does extensive usage of functions in JavaScript impact performance?" I vaguely remember hearing something about it somewhere, but couldn't recollect it, hence I sought the help of our savior, GPT-4 (Not 3.5, the differences between 3.5 and 4 are considerable. Also, I had 4 double-check its answer). Of course, as GPT's answer may not always be 100% accurate, let's take it with a grain of salt. Nonetheless, it was helpful for novices like us, so let's jot it down.

While using many functions per se doesn't significantly affect performance, there are specific instances where it can negatively impact performance. Here are some examples of performance degradation due to the usage of functions.

1. Unnecessary Function Calls

Needless to say, calling unnecessary functions can lead to a waste of memory and CPU resources. For instance, calling a function that performs the same calculation within a loop.

for (let i = 0; i < 1000; i++) {
  const result = someExpensiveFunction(); // A function returning the same result
  console.log(result);
}
Enter fullscreen mode Exit fullscreen mode

(Interestingly, even the function is named as "expensive".)
In such cases, moving the function call outside of the loop can optimize the performance.

const result = someExpensiveFunction(); // Call the function outside the loop

for (let i = 0; i < 1000; i++) {
  console.log(result);
}
Enter fullscreen mode Exit fullscreen mode

While my previous examples have provided some general strategies for optimizing JavaScript performance, it's essential to understand that the optimal approach can vary based on the characteristics of the specific function in question.

Consider, for example, a function such as 'someExpensiveFunction' mentioned earlier. If this function returns a different value each time it is invoked, moving the function call outside of a loop, as suggested in the blog post, might not always yield the desired outcome. In such cases, the function would need to be called within the loop to capture the changing return values.

It's important to weigh the frequency of change in the function's output against the computational cost of each invocation. If 'someExpensiveFunction' is computationally expensive but doesn't change its output frequently, it might still be beneficial to execute the function fewer times and store its result for multiple uses. On the other hand, if the function's return values change regularly and those changing values are essential for your application, calling the function within the loop might be the best course of action despite the computational cost.

In cases where we're dealing with stateless, computationally expensive functions, another effective optimization strategy can be the use of Web Workers.

Web Workers in JavaScript allow you to run computationally intensive tasks on a separate background thread, independent of the main execution thread of a web application. This means the main thread, which handles the user interface, remains unblocked and can continue to maintain a smooth and responsive user experience.

Here is an example of how to create a new Web Worker:

var worker = new Worker('worker.js');
Enter fullscreen mode Exit fullscreen mode

And within 'worker.js', you might have:

self.onmessage = function(event) {
  var result = expensiveComputation(event.data);
  self.postMessage(result);
};
Enter fullscreen mode Exit fullscreen mode

The main script can send messages (including data) to the worker thread and receive messages back from it:

// Sending a message to the worker
worker.postMessage(data);

// Receiving a message back
worker.onmessage = function(event) {
  console.log('Received result from worker: ', event.data);
};
Enter fullscreen mode Exit fullscreen mode

By offloading heavy computations to a separate worker thread, you can keep your main thread free to maintain a smooth UI, enhancing overall performance.

2. Non-Optimized Recursive Function Calls

When using recursive functions, a very deep call stack can lead to stack overflow or performance degradation. Tail Call Optimization can be used to address this.

function factorial(n) {
  if (n === 0) {
    return 1;
  }
  return n * factorial(n - 1); // Recursive function in need of optimization
}
Enter fullscreen mode Exit fullscreen mode

The following is an example of an optimized factorial function.

function factorial(n, accumulator = 1) {
  if (n === 0) {
    return accumulator;
  }
  return factorial(n - 1, n * accumulator); // Tail recursion optimization applied
}
Enter fullscreen mode Exit fullscreen mode

However, it's important to check whether the JavaScript engine supports tail call optimization. If not, using loops might be more efficient.

Tail Call Optimization

Tail Call Optimization is a programming technique to improve the performance of recursive functions. Understanding this optimization requires a basic understanding of recursive functions.

A recursive function is a function that calls itself and is used to solve problems by breaking them down. However, when using recursive functions, a deep call stack can lead to stack overflow. Tail call optimization can help overcome this issue.

Tail call optimization is a technique where the compiler or interpreter reuses the stack frame instead of piling it up when the last operation of a recursive function is to call itself. This reduces memory usage and the risk of stack overflow.

Let's look at how to write a factorial function applying tail call optimization.

Standard recursive function:

function factorial(n) {
  if (n === 0) {
    return 1;
  }
  return n * factorial(n - 1);
}
Enter fullscreen mode Exit fullscreen mode

Applying tail call optimization:

function factorial(n, accumulator = 1) {
  if (n === 0) {
    return accumulator;
  }
  return factorial(n - 1, n * accumulator);
}
Enter fullscreen mode Exit fullscreen mode

In the second function, the accumulator is an extra argument used to transform the function into tail recursion. This allows the compiler or interpreter to optimize the function and reuse the stack frame instead of adding more to it.

However, not all JavaScript engines support tail call optimization. The ECMAScript 2015 (ES6) standard includes this optimization technique, but the engines that actually support it are limited. Therefore, before using tail call optimization, it's important to check if your engine supports it. If not, using loops might be more efficient.

3. Excessive Use of Event Listeners

Excessive use of event listeners can burden DOM manipulation and may lead to memory leaks. This can be solved using event delegation.

// An example of an event listener usage that burdens performance
document.querySelectorAll('button').forEach((button) => {
  button.addEventListener('click', handleClick);
});
Enter fullscreen mode Exit fullscreen mode

Event Delegation is a method where you assign an event listener to a parent element and determine and handle the source of the event.

document.querySelector('#buttonContainer').addEventListener('click', (event) => {
  if (event.target.tagName === 'BUTTON') {
    handleClick(event);
  }
});
Enter fullscreen mode Exit fullscreen mode

When dealing with asynchronous functions that return a Promise, it's important to remember that JavaScript provides powerful tools to handle these scenarios efficiently. One of these tools is Promise.all.

Promise.all is a method that takes an iterable of promises and returns a new promise that fulfills when all of the input promises have fulfilled, or rejects as soon as one of them rejects. The value of the returned promise will be an array containing the fulfilled values of all input promises in the same order.

This can be particularly useful when you need to perform several independent asynchronous operations and want to wait until all of them have completed. Here is an example:

let promise1 = someAsyncFunction1();
let promise2 = someAsyncFunction2();
let promise3 = someAsyncFunction3();

Promise.all([promise1, promise2, promise3])
  .then(values => {
    console.log(values); // [value1, value2, value3]
  })
  .catch(error => {
    console.error('One of the promises rejected:', error);
  });
Enter fullscreen mode Exit fullscreen mode

In this example, Promise.all waits for all three asynchronous functions to complete. Once they have all resolved, their results are logged. If any of the promises is rejected, the catch block will handle the error.

By using Promise.all, you can ensure that all necessary asynchronous operations have completed before proceeding, enhancing the predictability and reliability of your code.

Conclusion

As seen in the examples above, although there can be instances where the usage of functions can affect performance, by applying optimization methods for each case, performance degradation due to function usage can be minimized. Proper usage and optimization of functions can enhance the readability and maintainability of code.

💖 💪 🙅 🚩
mochafreddo
Geoffrey Kim

Posted on May 28, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related