Javascript Performance

Javascript Performance

Welcome to the JavaScript section of my web performance series. In this segment, I'll be focusing on performance topics related to the code itself. For topics related to DOM manipulation and event listeners, please refer to the Browser part of the series. For bundle size and lazy loading, please check out the Network part. And If you're interested in the inner workings of the V8 engine and how to optimize your code to get the most out of it, head to the Engine part.

Write Better Code

First and most important this is investing time and effort into writing better code is crucial for achieving optimal performance. Simply put, better code leads to better performance. If your code is poorly written and disorganized, no amount of post-optimization techniques will be effective in improving its performance. The mentality of "it works" is no longer sufficient when it comes to performance optimization. In particular, spaghetti code is detrimental to performance. Therefore, it's essential to practice and study to improve your coding skills, which can enhance not only performance but also the overall quality of your code.

Some important parts of generally accepted conventions that affect performance are:

  • Consistency and Readability are essential factors in writing optimal code. If your code is difficult for you to follow, the compiled version may be unoptimizable and result in a "spaghetti" bytecode. To achieve optimal performance, it's essential to write code that is consistent, and easy to understand by you and consequentially by compiler.

  • Comments and Documentation ensure that the code is easy to read and maintain. While these factors may not directly affect performance, This, can drastically reduce the number of errors in the code and improve code that is produced by fewer experienced colleagues. It's important to remember that no optimization technique can improve code that doesn't work, so investing in clear documentation and readable code is a crucial aspect of developing performant software.

  • Reusability and Modularity are critical aspects of writing efficient code, as they help to reduce the amount of code you need to write. This results in smaller code that needs to be sent and compiled, leading to faster performance and network usage.

  • Monofunctionality is an important aspect of writing efficient JavaScript code. This is because modern JS engines such as V8 use advanced optimization techniques that can significantly improve the performance of your code. By ensuring that each function is designed to perform a single task and accepts the same type of properties, you can improve the effectiveness of these optimization techniques (more on this in the Engine part).

Coding can be a complex and challenging task, with many opportunities for mistakes and errors. These mistakes can vary depending on the project and environmental factors, making it crucial to stay vigilant and follow established coding conventions and best practices. While no one-size-fits-all solution exists for optimizing your code, adhering to conventions can help to minimize the chances of major errors and reduce the overall complexity of your codebase. Furthermore, small optimization mistakes are unlikely to have a significant impact on performance if the majority of your code is well-written and optimized. Therefore, investing time and effort in writing clear, maintainable code is a critical aspect of achieving optimal performance.

Specifics

Loops are the most important element to think about when speaking about performance because you are making some code re-execute multiple times and if this code isn't unoptimized or involves complex operations it becomes a huge problem, especially if loops are nested.

When dealing with nested loops, it's crucial to assess whether they're necessary or if they can be avoided. Often, refactoring your code can eliminate the need for a second loop altogether.

// Two loops
for (let i = 0; i < 5; i++) {
  for (let j = 0; j < 5; j++) {
    console.log(i, j);
  }
}
// Same result with one loop
for (let i = 0; i < 25; i++) {
  const row = Math.floor(i / 5);
  const col = i % 5;
  console.log(row, col);
}
// output
0 0
0 1
...
2 2
2 3
...
4 3
4 4

Also, you can compare speeds of different looping ways like for, forEach, map, filter, and reduce and use one that is fastest. However, this is more of a micro-optimization that can have no effect, because JS Engines optimize the hell out of your code anyway, so actually use one that fits the base. (speed comparison here)

Unnecessary object creation can be expensive because of its size or creating process, for example, if your object is the result of multiple function calls. This can be especially bad if objects are created inside a loop or other frequently-executed code. Try to reuse existing objects (this is a huge anti-pattern for functional programming) and avoid creating unnecessary objects.

Memoization is a technique where you cache the results of a function call so that you don't have to recalculate the result each time the function is called with the same arguments. This can be especially useful for expensive functions that are called frequently. Memorization can be functions argument, in case of recursive calls, stored in module variable or class property. E.g.

function fibonacciWithMemoization(n, memo = {}) {
  if (n in memo) {
    return memo[n];
  }
  if (n === 0 || n === 1) {
    return n;
  }
  memo[n] = fibonacciWithMemoization(n - 1, memo) + fibonacciWithMemoization(n - 2, memo);
  return memo[n];
}
// fibonacciWithMemoization(30) => 1143868.97 ops/s
function fibonacci(n) {
  if (n === 0 || n === 1) {
    return n;
  }
  return fibonacci(n - 1) + fibonacci(n - 2);
}
// fibonacci(30) => 73.5 ops/s

Caching frequently-used values, such as DOM elements or API responses, can significantly improve performance. This reduces the amount of time your code spends fetching and processing data. For working with APIs I highly recommend using libraries that have built-in caching functionality. Also, Such libraries include such things as optimistic updates, what this does is give you ability to immediately update cached data while your request to backend is still processing. E.g. your users who want to add a to-do to a list, instead of sending a new to-do and on successful response, send a new request to get a new list. your cached to-do list will immediately update and then a request to server will be sent.

Asynchronous code, such as callbacks, promises, and async/await, can improve performance by allowing other code to run while waiting for a resource to be fetched or a function to complete. E.g.

function fibonacciPromise(n) {
  return new Promise((resolve, reject) => {
    if (n < 1) {
      reject('n must be greater than or equal to 1');
    }

    let fib = [0, 1];
    for (let i = 2; i < n; i++) {
      fib[i] = fib[i - 1] + fib[i - 2];
    }

    resolve(fib.slice(0, n));
  });
}
console.log('Doing stuff')
// Starting fibonacci generation
fibonacciPromise(30)
  .then(result => console.log(result))
  .catch(error => console.error(error));
console.log('Still doing stuff')

Recursion VS Loops: Recursion can use more memory than loops because each time a function is called, a new set of local variables is created, resulting in a large number of function calls and memory usage. This large number of function calls can in itself stack overflow errors. Recursion can be simpler to write and understand for certain problems that involve recursive structures, while loops are often more suitable for problems that involve iterative solutions. However, loops are generally faster than recursion in terms of performance as they do not involve the overhead of creating and destroying function calls and variables. Although, in certain cases, recursion may be faster or more efficient.

Using short-circuit evaluations: instead of if-else statements using return to short-circuit evaluation can improve performance in some cases. Especially if you have multiple comparisons that are increasingly expensive. You can order then from the list to most or from most to less common to increase the chances of less amount of comparisons.

Micro-Optimization

Micro-optimizations are small changes in code that aim to improve performance by reducing the amount of processing time and resources used by the program. However, these optimizations are often only useful in specific circumstances, and may not make any noticeable difference in overall program performance. Moreover, micro-optimizations always sacrifice code readability, maintainability, and portability, making the code harder to understand and modify. As a general rule, it's important to prioritize code clarity, correctness, and maintainability over micro-optimizations, and only optimize specific parts of the code after profiling and benchmarking to ensure that the optimizations actually make a significant difference in performance.

Examples of insane micro-optimizations that have almost no gain, especially after optimizations that are done by the engine.

  1. Reducing the number of variables created, not creating a variable is faster than creating one, however, variables increase readability and give no notable performance gain.

  2. Using === instead of == can improve performance since it doesn't perform type coercion.

  3. Avoiding unnecessary property lookups: Repeatedly accessing object properties can be slow, so it's often better to cache the property value in a local variable if it's going to be used multiple times.

  4. Using the fastest looping construct: Different types of loops have different performance characteristics. For example, using a while loop instead of a for loop can be faster in some cases.

  5. Minimizing string concatenation: String concatenation can be slow, so it's often better to use an array to build up a string, and then join the array at the end.

  6. Using the most efficient data type choosing the most efficient data type for the task at hand can improve performance. For instance, using a typed array instead of a regular array can be faster for some operations.

  7. Avoiding try-catch blocks: Using try-catch blocks can be slower than using conditional statements, so it's important to use them sparingly.

  8. Reusing objects can matter a lot in case if in object creation is expensive and is conducted inside of a loop, but is an anti-pattern in any other case.