DEV Community

Aditya Tripathi
Aditya Tripathi

Posted on • Updated on

JavaScript Runtime and Code Lifecycle

In this article we will explore all the important aspects of JavaScript's runtime. Our aim is to understand the general inner workings of JavaScript's runtime, covering all the important details. Let's delve in!

 

Architecture Overview

JavaScript is a dynamic, single-threaded, JIT compiled, stack-oriented programming language. I have covered technical details of the language, in my other article: Technical introduction to JavaScript. It is important to understand these aspects beforehand to get the most of this article.

 

Lets start with a general architectural diagram of the browser runtime:

JavaScript Runtime Architecture

In the above diagram, the colourful part is the core JavaScript. Everything else is the environment which is responsible to provide a platform to run JavaScript code successfully.

Anytime we are running a JavaScript code, we are running it in some kind of environment, which is not a part of the language specification. A JavaScript Environment is a system that facilitates JavaScript code execution using an engine and extends JavaScript's functionality by providing different functions and APIs. For eg:

  • NodeJS is a JavaScript environment which use a JavaScript engine called V8 and provide APIs to create HTTP servers, handle file management and much more.

  • Chromium based browsers (like Google Chrome) are JavaScript environments which use V8 engine, same as NodeJS, and provide Web APIs (document, window, etc) to perform front-end specific task for creating powerful dynamic websites.

Other famous browsers have their own implementations of Web APIs and engines. For eg. Microsoft's Edge uses Chakra, Mozilla's Firefox uses SpiderMonkey, etc.

JavaScript Code Execution

JavaScript is a single-threaded language, which means it can only execute one task at a time. When a program is executed, the function being executed are called one by one for the execution. To keep track of the execution status and function calls, JavaScript utilise a stack data-structure called call-stack. To understand it better, lets consider an example:


function multiply(x,y){
  return x * y;
}

function square(n){
  return multiply(n, n)
}

let a = 10;

console.log(square(a))

Enter fullscreen mode Exit fullscreen mode

When the above code is run, each function call pushes the function being called into the call-stack. When the function has completed its execution, they are popped from the call-stack.

JavaScript call-stack for synchronous code

The above code is executed synchronously, meaning all the execution happens immediately, in one go, at a given point of time, without the control shifting over to some other entity. To contrast, lets modify our example to understand asynchronous code behaviour.

We can develop asynchronous code using JavaScript because of the Event Loop and Call-back Queue, provided by the runtime environment (see the architecture diagram above). These features provided by JavaScript runtimes enable use to write asynchronous code. JavaScript by itself is a blocking synchronous language.

In the code below, we use a function from Web APIs called setTimeout to create asynchronous behaviour. It enables us to call a function (passed as first parameter to setTimeout) after a mentioned time in milliseconds (second parameter) hence making the code asynchronous.

function multiply(x,y){
  return x * y;
}

function square(n){
  return multiply(n, n);
}

let a = 10;

setTimeout(function later(){console.log(square(a))}, 1000);

console.log('Hi')

Enter fullscreen mode Exit fullscreen mode

Upon execution, the function later is send to the call-back queue, to call it back after 1000 milliseconds, or one second. That is why, when we run the code, we first see Hi and then, at least after a second, 100 in the output. When the code is getting interpreted, the asynchronous functions are send to the callback queue. These are pushed to stack once the call-stack is empty, this is done by Event Loop.

JavaScrip call-stack for asynchronous code

By now we have a general idea of how JavaScript runtime works in synchronous and asynchronous operations. If you are interested in further explorations JS 900 is a great tool to explore synchronous and asynchronous language behaviour in-depth.

 
 

JavaScript Code Lifecycle

Let us go one level deeper in understanding how the code being executed is transformed end-to-end i.e. starting from source file all the way to generating machine code and executing it.

When we run a JS program in any environment discussed above, it goes to different phases before it is finally executed. These are namely Tokenisation, Parsing, Code-Generation and finally Code-Execution.

JavaScript Code LifeCycle

Novice developers often claim that JavaScript is an interpreted language, but as we will see below, this is only half-truth.

Any JavaScript program is always tokenised and parsed to generate an intermediate form of code called byte-code which is then interpreted by an Interpreter to generate executable machine code. This is different from purely interpreted languages where source code is directly converted to machine code for execution. (Difference between Byte Code and Machine Code)

A JavaScript program is dynamically compiled to byte-code, before it is interpreted and executed. This is why JavaScript is a 'Just-in-time' (JIT) compiled language. Notice that we used the term dynamically compiled. This is to reflect on the fact that JavaScript Engine doesn't have the luxury to pre-compile JavaScript and run it later (also called static compilation or Ahead-of-Time (AOT) compilation) like other compiled languages. Lets understand this whole jargon step-by-step in detail now. To set the premise, the JavaScript runtime under consideration is V8, used by Chromium based browsers and NodeJS.

We start with a sample JavaScript source code.

var sayHello = 'Hello World!';

function printMessage(message){
    console.log(message)
}

printMessage(sayHello)
Enter fullscreen mode Exit fullscreen mode

The engine starts with locating the source code. Generally, this code will either be on a file, in a Web Server or present in a script embedded in an HTML <script> tag.

 

Tokenisation

After identification, the source code is tokenised. The process is called tokenisation. During the process, JavaScript engine converts syntactically sound source code into tokens. This is the process where error in syntax are highlighted. In very simple terms, the source code is broken down into smaller valid language constructs. For eg. var sayHello = 'Hello World!'; is broken down into var a keyword to declare functional scope variables, sayHello an identifier, = an operator and 'Hello World' a string literal and finally ; a punctuator.

In some compiled languages, this process is also called lexing.

 

Parsing

Once the tokens are generated, they are passed to a Parser as input, which performs parsing. Parsing is the process to generate Abstract Syntax Tree (AST) and execution scopes. Generating AST allow us to represent the hierarchal structure of the program. This hierarchy information is used by Interpreter in subsequent steps for generating byte-code.

JavaScript AST

Notice that AST doesn't directly contain the value of the literals or variables. We will cover this later.

 

For V8 engine, there are two types of parsing: eager parsing and lazy parsing. The eager parsing generates both AST and execution scopes, while lazy parsing only generates AST.

Execution scopes, in simple terms are places which map identifiers to their assigned value. For our example a is mapped to 10 number literal. This information is stored in execution scopes, instead of directly putting them in AST, to keep the AST generation a fast process.

Eager parsing is used for top level code, which will be executed immediately by the Interpreter while lazy parsing is for the nested code and declarations which are not called and hence are not required for the execution of the program yet.

var sayHello = 'Hello World!'; // eagerly parsed

// eagerly parsed, but everything inside is lazy parsed for now
function printMessage(message){ 
    console.log(message)
}

// oh shit, need to parse everything eagerly inside, as the function is called.
printMessage(sayHello) 
Enter fullscreen mode Exit fullscreen mode

In eager parsing, the generated execution scopes are used to assign identifier values to variable proxies inside AST (as mentioned in the diagram above), which did not have any values assigned to them while AST generation. Lazy parsed AST are left without assigning the identifier values so that they are created faster and hence speed up the parsing process. The process of assigning identifier value from execution scopes to variable proxies in AST is referred to as Scope Analysis.

Lazy parsing is performed by a pre-parser according to the V8 terminology. Pre-parsers are twice as fast as Parsers because they skip execution scope generation and value assignments.

 

Code-Generation & Execution

Once the AST is generated and hydrated (variables proxies assigned with values), it is passed to a Byte-code Generator. It is responsible for producing a stream of byte-code, one byte-code at a time. Once the byte-code is generated, it is finally interpreted by an Interpreter to produce machine Code (0s and 1s). The machine code is then executed to produce the program output.

 
 

A word on Javascript Optimisations

As a side note, it is a good option to discuss some techniques like inline caching adopted by JavaScript engines to speed up the code compilation and execution process. JavaScript engines are capable of optimising code runs when a piece of code is being run multiple times.

As we have already discussed above, once the hydrated AST is fed to an Interpreter, it starts generating a stream of byte-code. While it is doing this, the output stream is passed to an 'optimising compiler' which is responsible for producing an optimised version of machine code, depending on what information it has from previous execution runs. Below is a very generic diagram of the process. The whole process is referred to as execution & optimisation pipeline.

JavaScript Optimisation Pipeline

 

When the Interpreter is generating byte-code, it also stores some sort of profiling data, which can be used in subsequent runs. When a function becomes 'hot' (i.e. when it has been run multiple times), the byte-code generated for that function, along with the profiling data is passed to the optimising compiler to create 'inline cache' for that function, which on future runs, are directly swapped in the byte-code stream instead of regenerating the byte-code.

Some engines have multiple optimising compilers instead of just one. Essentially all optimisations and caching techniques function on the fact that JavaScript engine is able to 'profile' the Object Shape (everything non-primitive in JavaScript is an Object). The article: JavaScript Shapes and Inline Caches explains the whole optimisation process in detail.

 
 

Conclusion

Our journey into JavaScript's runtime has unveiled its core mechanics, from architecture to code execution and lifecycle. We've grasped its dynamic, single-threaded nature, role of environments, and execution contexts. Understanding both synchronous and asynchronous code execution, the event loop's role, and diving into the lifecycle from source to machine code, we've unraveled JavaScript's essence as a "Just-in-time" compiled language. Further, optimisation techniques like inline caching have been explored, showcasing the intricate strategies boosting performance.

Until next time :)

Top comments (0)