How to write performant JavaScript code

How to write performant JavaScript code

JavaScript is slow, but don't make it any more slower.

ยท

9 min read

Introduction

JavaScript is slow.

I've never dug into JavaScript from the performance angle.

I got curious and ended up digging myself into a deep hole. ๐Ÿ™ˆ

Why is JavaScript slow

It's a bold statement to say JavaScript is slow.

But if we compare it to other programming languages like C, JavaScript is slower.

Let's dive into a few points that make JavaScript slower than some other programming languages.

Interpreted

Compared to a language like C, JavaScript isn't compiled. It's interpreted.

C compiles its source code down to machine code and that can get executed right away.

JavaScript has no compilation step. The browser interprets JavaScript code, line by line, and executes it.

Modern browsers use JIT (Just-In-Time) compilation. It compiles JavaScript to executable bytecode at runtime.

Dynamic Typing

JavaScript uses dynamic typing. The type of a variable can change at runtime. This flexibility comes with a cost.

It's because the interpreter needs to perform type checking at runtime.

Memory management

JavaScript offers automatic memory management, also known as garbage collection.

You don't have to manually manage memory when writing JavaScript.

This is another cost.

I will write about garbage collection in the future. Stay tuned.

Prototyped-based language

๐Ÿ˜› More information about JavaScript:

It isn't a class-based programming language like Java or C++. It handles properties and inheritance via its prototype system. Every JavaScript object has a prototype from which it inherits properties and methods.

  • Prototype chain: When accessing an object's property, JavaScript first looks for the property on the object itself. If it doesn't find it, it looks on the object's prototype and continues up the chain until the property is found or the end of the chain is reached.

  • Dynamic: You can change the prototype of an object at runtime, which would reflect on all objects inheriting from that prototype. This makes it more dynamic.

JavaScript engines

There are many different JavaScript engines. Their responsibility is to execute JavaScript code. They parse it into something the computer understands.

To mention a few:

  • V8

    • Used by Chrome and Node.js.
  • SpiderMonkey

    • Used by Firefox.
  • JavaScriptCore

    • Used in Safari.

Optimizations

JavaScript engines use different optimization techniques to improve performance. The optimizations are supposed to make the code run faster and more efficiently.

Let's look at a few examples.

Function inlining

Engine replaces a function call with the functionโ€™s body to eliminate the overhead of the call.

function add(a, b) {
  return a + b;
}

let result = add(5, 3); // This may be inlined to 'let result = 5 + 3;'

Type specialization

Engine optimizes operations based on the types of their operands.

function sum(a, b) {
  return a + b;
}

sum(1, 2); // Engine optimizes `sum` for numbers

Hidden classes

Engine creates and uses internal classes for objects with the same properties to optimize property access and assignment.

We'll dive deep into hidden classes later in this blog post.

function User(name, age) {
  this.name = name; // Same order of property assignment
  this.age = age;
}

let user1 = new User("Alice", 25);
let user2 = new User("Bob", 30);
// Both user1 and user2 share the same hidden class

What is deoptimization

๐Ÿ˜ฑ Now, as you probably imagined, optimizations don't always go right.

Every optimization the engine makes is an assumption. If the assumption doesn't go as expected, deoptimization occurs.

Deoptimization is the process when the engine's assumptions about the code turn about to be incorrect.

Deoptimization does happen and is costly:

  1. Throw away the optimized code: The optimized code is no longer valid.

  2. Reinterpret or recompile: The engine falls back to interpreting the code with fewer or different optimizations.

  3. Restore context: The engine must restore the program state to what it was before the optimized code started executing.

To better understand this, let's dive into the details of hidden classes.

Hidden classes

Hidden classes, also known as shapes or structures. They're an internal optimization concept used by engines like V8.

We developers can't directly access them. Hidden classes help optimize property access in JavaScript.

In languages with class-based object systems (C++, Java) the layout of object properties in memory is known at compile time. However, JavaScript is a prototype-based language. This means the engine has to determine the layout of properties at runtime. ๐Ÿ˜ถ

Every time an object is created in JavaScript, the JavaScript engine creates a hidden class for that object. This hidden class contains information about the shape of the object.

When accessing a property, the engine uses the hidden class to quickly find the property's value in memory.

Example

Let's take a look at some code and visualize what happens behind the scenes.

Let's say we have a dog object.

const dog = {
  name: 'Rufus',
} // first hidden class, only one property

dog.age = 3 // new property

dog.weight = 20 // new property

dog.color = 'brown' // new property

In the beginning, dog only has a name. ๐Ÿถ

As we add new properties, new hidden classes would get created.

Let's look at some visuals.

At the start, we have one hidden class describing the shape. It contains more information than what we see in this visual. For instance, name being of type string.

When we add the age property, an entirely new hidden class is created. dog will use the new hidden class.

The same applies when we add newer properties to dog.

Order of props matters

Let's take a look at some new code.

const dog1 = {
  name: 'Rufus',
  age: 3,
}

const dog2 = {
  age: 3,
  name: 'Rufus',
}

Will they use the same hidden class?

No, they won't. The order of properties matters. Both objects will use different hidden classes.

How does this apply to writing performant code?

We want to avoid deoptimization.

If the engine has to change hidden classes for an object during runtime, it comes with more cost.

After an object has been declared:

  • Avoid adding new properties

  • Avoid deleting properties

const dog = {
  name: 'Rufus',
  age: 3,
}

delete dog.age

Yes! Even this will cause the dog object to use a completely different hidden class causing deoptimization.

What if something is undefined at first?

Hidden classes are concerned with the structure of an object. The properties and the order of the properties. They're for the engine to optimize property access. You don't need to know the type of a value to access it.

Updating an existing property to a different type doesn't cause a different hidden class.

Here, the object uses the same hidden class before and after the change:

let obj = {
  prop: "string value"  // Initially, prop is a string
};

obj.prop = 123;         // Updating prop to a number

Pattern to avoid deoptimization

This means if a property doesn't exist at first, set it to undefined. We want to avoid deoptimization. It may feel strange, but it's fine.

const dog = {
  name: 'Rufus',
  age: undefined,
}

dog.age = 3 // `dog` still uses the same hidden class

Do this in TypeScript

If a property can be optional, set it to explicitly to undefined or null when declaring it.

Don't set it as optional and exclude the property from the object when declaring it.

Let's look at how to declare TypeScript types in a way that encourages avoiding deoptimization.

type WrongWay = {
  thisCouldBeUndefined?: string // This let's us exclude the property entirely
  anotherProperty: number
}

type RightWay = {
  thisCouldBeUndefined: string | null // this requires us to set the value to null
  anotherProperty: number
}

The right way of declaring the type requires us to set the property to its value or null.

This prevents the structure of the property to be different in the future. Therefore, we avoid potentially creating a new hidden class and causing deoptimization.

Seal your objects

Even when using TypeScript, this can be useful to prevent deletions.

Personally, I'd not worry about this at all. It feels like over-engineering. Unless you're building something that requires every nanosecond.

Sealing objects makes sure they can't have additional properties or properties deleted.

const object1 = {
  property1: 42,
};

Object.seal(object1); // not possible to add `property2` to object
object1.property1 = 33;
console.log(object1.property1);
// Expected output: 33

delete object1.property1; // Cannot delete when sealed
console.log(object1.property1);
// Expected output: 33

Be careful. Don't be tempted to do this for every object. Object.seal itself comes with a cost. As mentioned, you will rarely need this.

Later in this blog post, we dive into Map and how it may be more suitable for dynamic keys.

Writing performant JavaScript code: Tips & Tricks

So far, we've learned a lot!

In this section, I want to go over several things to keep in mind to write performant JavaScript code. ๐Ÿš€

Consistent argument types

Engines will try to optimize functions based on the types of arguments passed to them.

This is called Type Specialization.

When a function is called with arguments of certain types, the engine optimizes for those types. Calling the function again with different types causes deoptimization to happen.

After deoptimization, the engine re-optimizes.

function add(a, b) {
  return a + b;
}

add(5, 10); // Optimized for numbers
add("Hello, ", "world!"); // Causes deoptimization, now optimized for strings

Stick to using consistent argument types.

Check two arrays same value

Don't use nested loops to check if a value is in both arrays. This causes O(n^2) time complexity.

let array1 = [1, 2, 3];
let array2 = [2, 3, 4];
let intersection = array1.filter(value => array2.includes(value));

Instead, use a Set. This results in O(n) complexity.

let set2 = new Set(array2);
let intersection = array1.filter(value => set2.has(value));

Use Map for dynamic keys

Map is recommended for managing dynamic keys.

Learning this made me realize how much more often I should use Map.

  • Predictable Performance: Map objects have operations (get, set, has, delete) that perform well for dynamic keys. Additionally, they don't suffer from the side effect of having hidden classes like objects.

  • Key Flexibility: Maps can have keys of any data type, even boolean.

  • Independence from Prototype Chain: Maps do not have a prototype-like objects. So the operation of looking up a property in the prototype chain doesn't exist.

\=== is faster than ==

Interestingly, == can be up to 15 times slower than ===.

The Qwik team has written a fantastic article on this.

To summarize it shortly, it's because == performs type coercion when comparing values.

The good part: We want to use === because of the strict checking. So there isn't really any drawbacks here.

Conclusion

JavaScript may be slower than other programming languages.

But we love it, don't we? ๐Ÿซถ

ย