Javascript , Developers

Deep Dive Into Javascript Engines — Blazingly Fast ⚡️

A quick summary first. There is no single Javascript engine, there are a lot and I will go over some of them, most popular and used ones, speak about their differences and performance tips to optimize your application. Also a lot of these engines today can execute WASM. Engines have different compilers for that purposes ...

A quick summary first. There is no single Javascript engine, there are a lot and I will go over some of them, most popular and used ones, speak about their differences and performance tips to optimize your application. Also a lot of these engines today can execute WASM. Engines have different compilers for that purposes but I will not go into detail of that.

Remember that some engines may have different words for same things. I’ll try to mention these as well when needed.

Javascript Engines

Let’s speak about the engines in general first, what are they: They are basically programs that are executing our Javascript code because our computer knows nothing about Javascript. It only understands machine code so that’s what the engines does. Converting our Javascript code to machine code.

A question, is Javascript interpreted or compiled language? Well, that depends. Even though it is categorized as an interpreted language, is it really? So how the engines are working exactly? All of them works differently but also the same. Okay, no more riddles, let’s go deeper.

Is Javascript Interpreted Really?

That depends on the Javascript Engine that is executing the code actually. Some of the engines like V8, SpiderMonkey, JavascriptCore etc. are implementing JIT compiler which is known as Just in Time compiler. I will not go into detail of JIT but basically it’s best of two worlds, like both Interpreted and Compiled.

Also there are others that does runtime interpreting like Rhino (it works in both ways, compiled and interpreted), JerryScript, Boa etc.

So the answer to this questions is neither yes nor no 🙂 I’ll go through some of the engines above but remember some of them has different purposes. You can see the list in here.

Same Parts of the Engines

In the end, all of the engines share a lot underneath, they all are accepting the ECMAScript standards.As you know ECMAScript’s purpose is to ensurekeepinteroperability between different browsers (or in a lot of platforms today). What would happen if all the engines had different implementations for compiling Javascript? Yeah not something good 🙂

For example each Javascript object has some property attributes. You already kinda used them before without knowing, it exists in every object property.

This is the standardization for object property attributes. I’ll come to that property attributes later again that’s why I am giving this one as example.

The engines are responsible to follow ECMAScript standards. For example if you go for V8, they even have some nice comments that explains which line follows which standard. If you search for https://tc39.es/ecma262/#sec-validateandapplypropertydescriptor in the codebase you’ll see that which lines are for that standardization.

Working Flow of the Engines

First, remember that each engine might have different implementations which I will cover some of them and speak about their differences. Today’s most popular engines has similar flow in general.

In the above diagram we see that first our Javascript code is analyzed and parsed. That’s where Lexical analysis and Tokenization happens. Tokenization is basically dividing our code to pieces. Keywords, identifiers, punctuations, operators etc. See several samples from V8’s source code.

JavaScript
M(name, string, precedence)
  • K represents keyword tokens
  • E represents binary operator tokens
  • T represents non-keyword tokens

That is what AST (Abstract Syntax Tree) is created from, from these tokens. So what does an AST looks like? See the JSON representation of an AST for a basic variable declaration:

JavaScript
const a = 5;

If you want to try, go to astexplorer.net and see for yourself. It’s a nice online tool to show you the AST for the Javascript code. That website is not related with any engine. I mean this is what the engines are mostly creating but words might be little different etc. but the idea is the same.

After that AST is sent to Interpreter. Interpreter is basically interprets the AST, generates bytecode (different engines might have different kind of bytecode compilers and syntax) and collects feedback from it and send those feedback to the Optimization Compiler.

You can see the generated bytecode and optimized machine code in Node very easily with --print-bytecode and --print-opt-code flags.

Is your Javascript code runs fast? If yes you can thank to Optimization Compilers for that and of course your nice, clean and performant code 🙂 Optimization Compiler may do some speculations based on the previous information but not all the time that these speculations are correct. In these situations the code needs to be deoptimized and there are some things that we can do about it. We’ll see details about this but first let’s speak about several popular engines.

V8 Engine

Probably today’s most popular engine, developed by the Chromium Project and written in C++. It’s powering Google Chrome, Chromium web browsers, Node & Deno Runtimes etc.

I already gave you some samples from V8. What are the differences from the above flow in V8? The flow is almost the same. V8’s Interpreter called Ignition and Optimization Compiler called TurboFan

JavascriptCore (JSC) Engine

JavascriptCore is a Fork from KDE JS engine but it’s come a long way since then. Written mostly in C++ and also some Objective-C, Ruby etc. It’s also known as SquirrelFish and SquirrelFish Extreme. Also within the context of Safari Browser it’s known as Nitro and Nitro Extreme. It’s powering Safari and many other apps in Apple ecosystem, Bun Runtime, React Native (I’ll not cover them but this also has some details depending on the mobile OS. You can also use Hermes Engine too, which is developed by Meta for React Native) etc.

What about the flow? Well, this is a little different in JSC. I mean the general logic is the same but it has more than one JIT Compiler, they don’t have a single Optimization Compiler like V8 does.

JSC has an Interpreter and 3 different JIT Compiler for Optimization. Okay what are those:

  • LLInt stands for Low Level Interpreter. It’s executing the bytecode (Profiling Tier)
  • Baseline JIT it’s a template JIT and the output is very similar to LLInt (Profiling Tier)
  • DFG is low-latency optimizing JIT which produces less optimized code (Optimizing Tier)
  • FTL is high-throughput optimizing JIT which produces fully optimized code (Optimizing Tier)

Each tier does the optimizations based on the collected information from the lower tiers. Are all of them working at the same time? Not exactly, it depends. JSC has a process called tiering-up. This tiering-up is done with the help of OSR (On Stack Replacement).

On Stack Replacement is a technique for switching between different implementations of the same function. In this case different implementations in each tier. Also as I said before, different engines has different words for same things, deoptimization is known as OSR-Exit in JSC.

There are different thresholds for each tier (remember that all of these numbers are approximate):

Baseline JIT kicks in for functions that are invoked at least 6 times, or take a loop at least 100 times (or some combination — like 3 invocations with 50 loop iterations total).

DFG JIT kicks in for functions that are invoked at least 60 times, or that took a loop at least 1000 times.

FTL JIT kicks in for functions that are invoked thousands of times, or loop tens of thousands of times.

SpiderMonkey Engine

The first Javascript Engine. Created by Brendan Eich (also the creator of Javascript) in 1995. Today it’s currently maintained by Mozilla Foundation. It’s mostly written in C++ and Rust. It’s powering Firefox, MongoDB Shell and various others. Also there is a Node Runtime that uses SpiderMonkey as Engine, called SpiderNode.

The SpiderMonkey is again pretty same in general logic with V8 and JSC. Like JSC, SpiderMonkey has more than one JIT Compiler. Baseline Compiler and WarpMonkey.

Optimization Compilers

Now, since we have learned some of the engines and how they’re working, let’s go deep into the Optimization Compilers and what they’re optimizing.

Keep in mind that each engine might have different optimization techniques but in general they’re mostly similar so I’ll go over generally and not stick to a single engine. Also remember that a lot of other language’s compilers does similar optimizations.

Inlining

Inlining is the process of replacing a subroutine or function call at the call site with the body of the subroutine or function being called.

Let’s go over an example, see the below function:

JavaScript
const add = (a, b) => {
    return a + b;
};

for (let i = 0; i < 100000; i++) {
    add(i, i * 2);
}

What optimizations can be done to this? When you call add it has some drawbacks. Calling functions is more expensive than writing function bodies directly (not true all the time) because there is a call overhead, why? Because when you call a function a lot of things are happening underneath one of them is a new stack frame will be created and pushed on top of the stack with all function data.

Moving add function’s body to the calling function’s (in this case for loop’s) body will look something like this:

JavaScript
for (let i = 0; i < 100000; i++) {
    i + i * 2;
}

Loop-Invariant Code Motion (LICM)

Also known as Hoisting (don’t confuse yourself with the hoisting in Javascript, it is kinda similar but it’s not that) or Scalar Promotion.

It’s basically doing the same thing over and over again in the loop for the statements which the result never changes so the compiler just moving it out of the loop. Let’s see the example:

JavaScript
let sum = 0;

for (let i = 0; i < 1000; i++) {
    sum += 10 + 10 * 2;
}

The problem here is that we are calculating the 10 + 10 * 2 a thousand times. We can move the calculation out of the loop and use the result in the loop.

JavaScript
let sum = 0;
let result = 10 + 10 * 2;

for (let i = 0; i < 1000; i++) {
    sum += result;
}

Common Subexpression Elimination (CSE)

Think of this like DRY, don’t repeat yourself. Let’s see the example directly:

JavaScript
const a = 5;
const b = 10;
const c = (a * b) / 2;
const d = (a * b) + (a * b);

Did you see that? We are calculating a * b three times. We can eliminate doing the same calculation by moving the calculation result into a variable.

JavaScript
const a = 5;
const b = 10;
const tmp1 = a * b;
const c = tmp1 / 2;
const d = tmp1 + tmp1;

Dead Code Elimination (DCE)

Also known as Tree Shaking

Do you remember that sometimes your IDE & Editor warns you about the code that is never used, function that is never called, or code that is unreachable? Yeah compiler just throw it away. (even some bundlers does that like Babel, Webpack, Esbuild etc.)

Strength Reduction

That is about replacing expensive operations with equivalent but cheaper ones.

JavaScript
let x = 7;
let arr = [];

for (let i = 0; i < 10; i++) {
    arr[i] = x * i;
}

Above code basically calculates the numbers that are multiplies of 7 between 0–10. How can we replace this code with a cheaper version?

JavaScript
let x = 7;
let s = 0;
let arr = [];

for (let i = 0; i < 10; i++) {
    arr[i] = s;
    s += x;
}

How is that faster? In CPU level addition is faster than multiplication, as you can see we removed the multiplication. I am not sure if this difference is valid for all type of CPUs but for opposite you can still do strength reduction.Also for example your code might do 5 operations to calculate it and with strength reduction you can made it to 4.

Array Built-ins

Do you know how built-in array functions that we use like .map(), .some() etc. are implemented? Let’s see the ECMAScript Specification for .some():

At the end this specification implementation for below function call:

JavaScript
[true, true, false].some(a => a === true);

will look something like this (take this as O in the specification):

JavaScript
const someMethod = () => {
    let callbackfn = x => x === true;
    let len = this.length;
    if (typeof callbackfn !== "function") {
        throw new TypeError();
    }
    let k = 0;
    
    while (k < len) {
        if (k in this) {
            if (callbackfn(this[k])) {
                return true;
            }
        }
        k = k + 1;
    }
    return false;
}

Engines can optimize a lot of that code, for example one of them is deleting the if (typeof callbackfn !== "function") it is redundant, compiler already knows that callbackfn is a callable.Second, we can remove if (k in arr) because we are already looping over it len size so it’s not possible that statement to be return false. Done? Not yet :)As we know, we can also inline the callbackfn. At the end code will look like this:

JavaScript
const someMethod = () => {
    let len = this.length;
    let k = 0;
    
    while (k < len) {
        if (this[k] === true) {
            return true;
        }
        k = k + 1;
    }
    return false;
}

There are much more optimizations that the compilers does but I’ll not cover all of them.

Even though compilers already does some of these optimizations, try not to write code like that unless it affects your practices and style. I mean of course do not inline your functions on your own because it makes your code harder to read but move variables out of loop that are not changing in the loop. Same for some others like CSE, do not repeat your calculations over and over again.

Performance Optimizations That We Can Do

Until now, engines does everything, executing our code, optimizing our functions but can’t we do anything to help the engine? Yeah we can help the engine and increase performance. I’ll speak about several things that can we can do to help.

Hot Functions

We spoke about ECMAScript specifications. Let’s take ApplyStringOrNumericBinaryOperator as example which what + operator calls.

As you can see we have some checks, if it’s a string, if it’s a number, other calls etc. Assume that we have the below function.

JavaScript
const add = (a, b) => {
    return a + b;
};

for (let i = 0; i < 100000; i++) {
    add(i, i * 2);
}

add("Doğukan", "Akkaya");

We are calling add function a hundred thousand times. As you know we can use + operator with both strings and numbers. In the loop we are passing number for both arguments. After calling functions with number type of arguments multiple times engines are making speculations, remembers the types passed in so it can eliminate checks that ECMAScript specification wants. add becomes monomorphic in the for loop. Now after that, we call it with different type of arguments and it becomes polymorphic. We can trace the optimizations and deoptimizations with Node:

As you can see I am running the add function for a hundred thousand times, all calls with number arguments. What happens if we call it with a string argument now?

As you can see, code is optimized again first but after the loop, function is called with different types so it’s deoptimized.

Shapes

This is again something that most of the engines named differently. JSC calls them Structures, SpiderMonkey calls them Shapes, V8 calls them Maps etc. They are also very commonly called Hidden Classes

Do you remember that I said I will come to that ECMAScript specification for property attributes later, now is the time.

What that specification means every property of an object has to have this attributes somewhere. Let me visualize that with the reference of that code:

JavaScript
const object = { x: 1 };

Okay but what about if there is one more property in the object?

JavaScript
const object = { x: 1, y: 2 };

For each property of an object, property attributes created but this is not very good right? Because we have lots of objects and lots of properties in those objects in our programs. There will be a huge memory waste if we do it like that (shapes also has some performance benefits over using hash based dictionary). This is where the shapes comes in to play. Let’s say we have thousands of objects with the same properties.

JavaScript
const object1 = { x: 1, y: 2 };
const object2 = { x: 2, y: 3 };
// ...
const object1000 = { x: 1000, y: 10001 };

Engines does not create thousands of property attributes, instead they use shapes.

Instead of creating same property attributes over and over again, engines creates shared shapes. These shapes points to property attributes and inside the property attributes we have the offset that points to the real value.

What happens when we add new properties to objects then?

JavaScript
const object = { x: 1, y: 2 };
object.z = 3;

Of course the shape is not changing because there might be other objects that already uses that shape but still we can reuse, how?

First, engine creates another shape for the new properties and this new shape contains also z property. You may ask why not second shape only contains z because z is not a shape on it’s own, there might also be an object that only has z property in it and the offset of that might be 0.

Also remember that ordering of properties is important because we are storing the offset value.

JavaScript
const object1 = { x: 1 };
const object2 = { x: 2 };

object1.y = 2;
object2.z = 3;
object1.z = 4;
object2.y = 5;

Since ordering is not the same, we will have to create different property attributes and shapes for each new property but if we create new properties in the same order then they will share the shape which is good because it saves memory.

JavaScript
const object1 = { x: 1 };
const object2 = { x: 2 };

object1.y = 2;
object2.y = 3;
object1.z = 4;
object2.z = 5;

You can inspect these in Node, V8 has some interesting stuff to help us.

Don’t worry if your IDE & Editor complains about the syntax, it’s not something specific to Javascript but to V8. You can see the list of this kind of functions from here.

What happens if you create an empty object and then add properties to it?

JavaScript
const object = {};
object.x = 1;

A new shape is created for an empty object and then another shape is created for x property.

Remember, even though most of the engines implemented Hidden Classes with the same logic generally but their model might be different from each other.

Inline Caching (IC)

Each time you are writing object.x there will be some job to do for the engine. It will do some lookups, it will go to the shape, find x, find it’s property attributes, find the offset, finally go to the object values and find the correct offset. Engines can make this more performant.

JavaScript
const getName = (person) => {
  return person.name;
};

getName({ name: "Doğukan" });
getName({ name: "Doğukan" });
// ...

In the above code, we call getName function with an object multiple times (so it becomes hot after some calls). Engines will do the job that I mentioned and then return the person.name but after it’s optimized, do we really need to do the same thing again? No, engines are remembering that as we speak in the Hot Functions section. It basically says “You called this function with the same parameters before and I know exactly where the name property is”. Think ICs like shortcuts for properties.

Remember, if you pass lots of different shapes of objects to a function, some of the engines won’t even try to optimize it and always go to slow path.

Performance tips:

  • Do not create functions that takes a lot of optional properties. Try to be consistent of your object shapes and argument types.
  • Always try to set your properties in the object initialization.
  • If you can’t set your properties in the object initialization then add your properties in the same order.
  • Make your functions monomorphic as much as you can. Always try to think like you are writing statically typed language (using Typescript can help).

Resources

A lot of conference videos and lots of blog posts but some of the names that I took advantage of a lot are Fransizka Hinkelmann, Michael Saboff, Benedikt Meurer etc. — 👏

Dogukan Akkaya is a fullstack developer here at Cloudoki. Be sure to follow his blog over on medium to stay up to date with his latest posts: https://medium.com/@dogukanakkaya

Related Articles

Secure web applications
Developers Security

Building Secure Web Applications in the Age of Cyber Threats: Essential Strategies

Build secure web applications that withstand evolving threats. Learn secure coding practic...

Read more
About us Blockchain Developers DevOps

Infrastructure, Blockchain, and Scalability with Erick Rettozi

In this article, Cyrex' Lead Backend Engineer Erick Retozzi offers expert advice on buildi...

Read more
APIs Developers

The Hidden Costs of DIY API Integration: When to Consider Professional Help

Avoid costly mistakes with professional API integration. Learn the hidden costs of DIY API...

Read more
AI Developers

LLMs, the gist

Unlock the magic of Large Language Models (LLMs) with our comprehensive guide. Discover ho...

Read more