Not every piece of code needs to be perfectly optimized. Over-focusing on performances can sometimes lead to code that’s unnecessarily complex and unreadable without real benefits. However, being aware of common performance pitfalls lets us navigate and avoid them effectively.
What is performance?
When we talk about performance, we’re referring to how efficiently a program runs. Typically, we analyze it in terms of time and memory usage:
- Analyzing the time allows us to describe how long a program takes to execute.
- Analyzing memory lets us point out how much memory a program consumes when running.
Sometimes, we can make our application faster by letting it consume more memory and vice versa. Our goal isn’t always to make everything as fast as possible but to find the right trade-offs for a given situation.
Nowadays, we tend to focus more on optimizing time than memory consumption. In most cases, slow execution is a more significant bottleneck than high memory usage. Because of that, we focus on the time complexity in this article.
Analyzing performance
The performance of our application can depend on multiple things, and one of them is how well we write our code. However, there are also outside factors, such as the machine we run it on. Because of that, we need an objective way of analyzing the performance that’s not affected by variables like that.
Let’s start with a simple array of users.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
const users = [ { id: 2, name: 'John', job: 'Frontend Developer' }, { id: 8, name: 'Amy', job: 'User Interface Designer' }, { id: 10, name: 'John', job: 'Project Manager' }, { id: 13, name: 'Olivia', job: 'Fullstack Developer' } ] |
Now, let’s write a function that returns a user with a given id.
1 2 3 4 5 6 7 8 9 |
function getUserWithId(usersArray, id) { const n = usersArray.length; for (let i = 0; i < n; i++) { const user = usersArray[i]; if (user.id === id) { return user; } } } |
There are a couple of ways to look at the efficiency of our function.
The best-case scenario
In the best-case scenario, the user we are looking for is in the first element of our array. Thanks to that, our function needs to enter the loop only once.
1 |
getUserWithId(users, 2); |
The worst-case scenario
In the worst-case scenario, the user we’re looking for is in the last element of the array. To find it, our function needs to go through the entire array.
When analyzing performance, we typically care the most about the worst-case scenario.
1 |
getUserWithId(users, 13); |
The Big O notation
When analyzing performance, we don’t necessarily count the exact number of operations our code needs to perform. Instead, we examine how the number of operations grows as our dataset increases. For example, if we have an array with ten elements, our getUserWithId function needs to check all ten elements in the worst case before finding a match. If the array size triples, the number of checks also triples since the loop goes through every element in the array.
We can use the Big O notation to describe the performance of our function in the worst-case scenario. It allows us to note how quickly the number of steps in our code grows as the dataset gets bigger. The time complexity of our getUserWithId function is O(n), where n represents the size of our dataset.
The O(n) complexity means that in the worst-case scenario, the number of steps grows at the same rate as the input size. For example, if our users array grows ten times, processing it will take roughly ten times more steps.
O(n) is called a linear time complexity where the number of steps grows at the same rate as the input size. The O(n) complexity is rather common and usually acceptable if our dataset is not huge and the function isn’t executed too frequently.
Quadratic time complexity
Let’s write a function that checks if some users share the same name.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function checkForDuplicateNames(usersArray) { for (let i = 0; i < usersArray.length; i++) { const user = usersArray[i]; const allUsersWithTheSameName = []; for (let j = 0; j < usersArray.length; j++) { const userToCheck = usersArray[j]; if (userToCheck.name === user.name) { allUsersWithTheSameName.push(userToCheck); } } if (allUsersWithTheSameName.length > 1) { return true; } } return false; } |
In the above function, we go through each user in our array and check how many users share their names.
A big red flag we should notice is that our function has a loop nested inside of another loop. For example, if our users array has ten elements, the following code runs a hundred times in the worst-case scenario:
1 2 3 |
if (userToCheck.name === user.name) { allUsersWithTheSameName.push(userToCheck); } |
If the size of our dataset is multiplied n times, the number of steps increases n² times. For example, if the array size increases 5 times, the number of operations is increased 25 times.
Because of the above, the time complexity of our checkForDuplicateNames function is O(n²).
In the above code, it’s easy to notice because we’re putting one for loop directly inside another one. It might be slightly less obvious if our function calls another function that uses a loop. It’s also crucial to know that functions like forEach, map, filter, or some perform loops under the hood.
1 2 3 4 5 6 7 8 9 |
function checkForDuplicateNames(usersArray) { return usersArray.some((user) => { const allUsersWithTheSameName = usersArray.filter((userToCheck) => { return userToCheck.name === user.name; }); return allUsersWithTheSameName.length > 1; }); } |
Above, we use the filter function inside some. Because of that, the time complexity of checkForDuplicateNames is still O(n²).
Thankfully, we can rewrite our function so that it has the O(n) complexity.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
function checkForDuplicateNames(usersArray) { const usedNames = {}; for (let i = 0; i < usersArray.length; i++) { const user = usersArray[i]; usedNames[user.name] = usedNames[user.name] || 0; usedNames[user.name] += 1; } console.log(usedNames); /* { John: 2, Amy: 1, Olivia: 1 } */ for (let i = 0; i < usersArray.length; i++) { const user = usersArray[i]; if (usedNames[user.name] > 1) { return true; } } return false; } |
Technically, the complexity of our above function could be described as O(2 * n) because we’re doing two loops. However, in Big O notation, we omit constant factors such as multiplying by a number. Instead, we focus on how the number of steps increases relative to the input size rather than the exact number of operations. Therefore, we can say that the complexity of our function is O(n).
Still, that should not stop us from improving our checkForDuplicateNames function to use a single loop.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
function checkForDuplicateNames(usersArray) { const usedNames = {}; for (let i = 0; i < usersArray.length; i++) { const user = usersArray[i]; usedNames[user.name] = usedNames[user.name] || 0; usedNames[user.name] += 1; if (usedNames[user.name] > 1) { return true; } } return false; } |
Preparing our data
Previously, we wrote a function that returns a user with a given id.
1 2 3 4 5 6 7 8 9 |
function getUserWithId(usersArray, id) { const n = usersArray.length; for (let i = 0; i < n; i++) { const user = usersArray[i]; if (user.id === id) { return user; } } } |
We established that its time complexity is O(n). It’s fine as long as our dataset is not too big and we don’t use this function very often. However, if we have a lot of elements in our array and we call the getUserWithId function often, its performance might not be good enough.
To change that, we can transform our array of users to an object where each key corresponds with the user ID.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
const usersDictionary = users.reduce((result, user) => { result[user.id] = user; return result; }, {}) console.log(usersDictionary); /* { 2: { id: 2, name: 'John', job: 'Frontend Developer' }, 7: { id: 7, name: 'Amy', job: 'User Interface Designer', }, 9: { id: 9, name: 'John', job: 'Product Manager', }, 12: { id: 12, name: 'Olivia', job: 'Fullstack Developer', } } */ |
Transforming our array into the above dictionary has the complexity of O(n) because we loop over each user once. This complexity is the same as the getUserWithId function we wrote before. However, if the array of users doesn’t change, we need to do this only once.
Now, we can access each user instantly.
1 2 3 4 5 |
function getUserWithId(dictionaryWithUsers, id) { return dictionaryWithUsers[id]; } getUserWithId(usersDictionary, 2); |
Thanks to the above change, our getUserWithId function now has the O(1) complexity. This means the number of operations doesn’t change when the dataset grows. Even if we had ten times as many users, the getUserWithId function would perform the same number of operations.
The above is a good example of increasing our memory complexity to decrease the time complexity. When doing that, we had to create the usersDictionary variable that takes additional memory.
Summary
In this article, we’ve explained how we can analyze the performance of our code. To do that, we used the Big O notation and explored different time complexities. We also noticed that restructuring our data, such as converting an array to an object, can help us make our code faster by increasing memory usage.
Understanding how to analyze performance can help us write more scalable code. While we shouldn’t focus on optimization prematurely, understanding the basic principles can help us avoid common performance pitfalls.
Really interesting