~/DHRUVUpskilling
← board/JS / TS/Javascript Engine/js-engine-01
Solving·24 Aug

JavaScript Engine Concept

DifficultyMedium
PatternJavascript Engine
TrackJS / TS
tl;dr

Here I'll focus on learning Javascript core working

full write-up

How Does JavaScript Work?

Before we learn how JavaScript works, we need to understand two words: Interpreter and Compiler. These are two different ways a computer can run our code.

What Is an Interpreter?

An interpreter runs our code line by line. It does not look at the full code first. It just reads one line, runs it, then moves to the next line.

  • Interpreters are usually fast to start. They don't need to convert the code into another language first.
  • But they have a problem. If a loop runs the same line many times (like 10,000 times), the interpreter runs that line 10,000 times too. It does not know that the result will always be the same.

What Is a Compiler?

A compiler works in a different way. It reads the whole code first. Then it understands the full picture. After that, it converts the code into a new, simpler form.

  • If the compiler finds a part of the code that is slow or repeated, it can make it simpler.
  • Example: If a loop always calculates 2 + 2 for 10,000 times, the interpreter will calculate it 10,000 times. But the compiler will just replace it with the answer, 4, one time.
  • The downside: Compilers are slow at the start, because they need time to read and convert the whole code first.

Can We Use Both Together?

Yes. This is why we have the JIT Compiler (Just-In-Time Compiler). JIT tries to take the good parts of both the interpreter and the compiler.

Here is how it works, step by step:

Step 1: Code Becomes Tokens (AST)

When we write code, it first gets broken into small pieces called tokens. These tokens are arranged into a tree structure called an Abstract Syntax Tree (AST).

Below is a simple example. This is the AST for console.log('tips'). You can check the AST of any code on astexplorer.net.

{
  "type": "Program",
  "start": 0,
  "end": 200,
  "body": [
    {
      "type": "ExpressionStatement",
      "start": 179,
      "end": 199,
      "expression": {
        "type": "CallExpression",
        "start": 179,
        "end": 198,
        "callee": {
          "type": "MemberExpression",
          "start": 179,
          "end": 190,
          "object": {
            "type": "Identifier",
            "start": 179,
            "end": 186,
            "name": "console"
          },
          "property": {
            "type": "Identifier",
            "start": 187,
            "end": 190,
            "name": "log"
          },
          "computed": false,
          "optional": false
        },
        "arguments": [
          {
            "type": "Literal",
            "start": 191,
            "end": 197,
            "value": "tips",
            "raw": "'tips'"
          }
        ],
        "optional": false
      }
    }
  ],
  "sourceType": "module"
}

Step 2: AST Becomes Bytecode

The AST goes to the interpreter. The interpreter turns it into bytecode. Bytecode is a form of code that the JavaScript engine can read and run.

Step 3: The Profiler Watches the Code

While the code runs, a helper called the Profiler (or Monitor) watches it. The profiler looks for code that runs many times. This kind of code is called "hot" code.

Step 4: JIT Compiles the Hot Code

The JIT compiler now takes only the hot code (the code marked by the profiler) and turns it into optimized machine code. Machine code is very fast because the computer's hardware can run it directly.

This new fast code is swapped in while the program is still running. This swap is called On-Stack Replacement (OSR).

Step 5: Sometimes the Engine Has to Undo Its Work

Sometimes, the JIT compiler makes a guess. For example, it may guess that a variable is always a number.

  • If that guess stays true, the fast machine code keeps running.
  • If that guess breaks later (someone puts a text value in the variable instead), the engine deoptimizes. This means it throws away the fast machine code and goes back to the slower bytecode and interpreter.

This is why using the same data type for a variable in your hot functions is a common tip for better JavaScript performance.

A Simple Example: The Life of a Function

Let's look at a function called foo() and see how the engine treats it over time:

  1. foo() is called for the first time. There is no machine code for it yet, so the interpreter runs it using bytecode.
  2. The profiler notices that foo() is called many, many times. It marks foo() as "hot."
  3. The JIT compiler turns foo()'s bytecode into fast, optimized machine code. This happens in the background, so it does not stop the program.
  4. The next time foo() is called, the engine checks: "Do I already have machine code for this?" The answer is yes. So it skips the interpreter and bytecode, and runs the machine code directly.
  5. If foo()'s earlier guesses break (for example, a variable's type changes), the engine deoptimizes. It throws away the machine code and goes back to bytecode and the interpreter. Later, it may try to optimize again with better guesses.

Summary

  • An interpreter is fast to start but slow for repeated code.
  • A compiler is slow to start but fast for repeated code.
  • A JIT compiler mixes both: it starts fast like an interpreter, and speeds up hot code like a compiler.
  • The JIT uses a profiler to find hot code, compiles it into machine code, and deoptimizes it if earlier guesses turn out to be wrong.