Under the Hood of JavaScript’s Async Engine


Oct 19, 2024 · 9 min read
Oct 19, 2024 · 9 min read
Most computers handle tasks asynchronous by design. Asynchronous means that things can happen independently of the main program flow. Exception from the multiprocessor machine , every program runs for a specific time slot and then it stops its execution to let another program continue their execution. This process runs in such fast cycle , it appears as if the programs are running simultaneously. We think our computers run many programs simultaneously, but this is an illusion. At the OS level, programs uses interrupt-signal that's emitted to the processor to gain the attention of the system.
JavaScript executes the tasks synchronously by default, and it operates on a single thread. This means it process tasks sequentially one after another, without creating additional new threads, for example:
const a = 1;
const b = 2;
const c = a * b;
console.log(c);
doSomething();
A callback is a simple function that's passed as a value to another function, and will only be executed when a specific event or conditions occurs. We can do this because JavaScript has first-class functions, which can be assigned to variables and passed around to other functions (called higher-order functions).
Lets take a button on a browser.
document.getElementById('button').addEventListener('click', () => {
// item clicked
});
In this case, the callback function is executed when the button is clicked. This ensures that the click event triggers the callback response asynchronously, without blocking the rest of the script. But how does the execution of this works?
JavaScript has evolved beyond just client-side scripting, largely thanks to Node.js, a powerful runtime that allows JavaScript to be executed on the server. But how exactly does Node.js execute JavaScript? Let’s break it down.
"Node.js is a single-threaded javascript runtime built on chrome's V8 javascript Engine. Node.js uses an event-driven, non-blocking IO model that makes it lightweight and efficient." Lets define those terms one by one.
What is a runtime?
A runtime is an environment in which a code is executed. in case of Node.js, the runtime allows a javscript code, which traditionally runs on a browser. What node does is create an environment which allows this browser script to be executed on a server or any machine outside of a browser. Node does this by providing the necessary libraries, api and tools to execute javascript code in the backend context.
What is chrome's V8 javascript Engine?
Is it car's V8 engine?
No. Rather it is an open-source javascript engine developed by Google. Its main responsibility is compiling javascript into a machine code, enabling fast execution. Node and browsers uses this engine to execute the javascript code.
What is Event-driven model?
An event-driven model means the execution of the javascript is based on events and callbacks. When an event occurs (like IO operation), the corresponding callback function is triggered. This makes it easier to handle asynchronous operations. What's special about it? we will see it in detail below?
What is non-blocking I/O model?
When there is any IO bound operation, node does not need to wait for them to be complete before executing the next task. Instead the program continues with other tasks while waiting for the IO operations to finish. when the operation is completed , An event is triggered to notify the program. This is in contrast to blocking IO where the program wait for the IO operation to complete before executing another task.
What is 'Lightweight and Efficient' means?
Does this means node takes less space in storage devices? No. When we say Node is lightweight we means it avoids creating large heavyweight threads to handle individual tasks, rather it used a single threaded event-loop combined with the non-blocking IO model to handle multiple tasks concurrently.
Is Node.js single-threaded?
Yes, Node runs on a single main thread of execution. It uses a single thread to manage multiple operations. Is is better than old frameworks multi-thread or is node truly a Single threaded? We will dive into it below topics.
We all know that JavaScript is single-threaded and synchronous by nature. It processes one task at a time. However, JavaScript is capable of asynchronous operations through mechanisms like callbacks, promises, and async/await, which allow it to handle tasks without blocking the execution of other tasks.
Let’s take this basic example, which runs synchronously:
const a = 1;
const b = 2;
const c = a * b;
console.log(c); // Outputs: 2
doSomething();
In the above code, each line executes one after the other. However, when dealing with IO operations like reading files or making network requests, we don't want our program to wait for those operations to complete.
JavaScript callbacks are used to handle asynchronous operations. Here's a basic example:
function doSomethingAsync(callback) {
setTimeout(() => {
console.log("Operation complete");
callback();
}, 2000);
}
doSomethingAsync(() => {
console.log("Callback called!");
});
Here, the setTimeout()
function simulates an asynchronous operation, and the callback is called once the operation is complete.
The event loop is an important mechanism that allows JavaScript to handle asynchronous operations despite being single-threaded. It manages the execution of the code, collects and processes events, and executes queued sub-tasks, Think of it like a manager overseeing and coordinating all the tasks in a company."
Here’s how the event loop works:
setTimeout
, I/O operations
, etc.) complete.
Here’s how the event loop processes macrotasks and microtasks:
console.log('Start');
setTimeout(() => {
console.log('Macrotask 1');
}, 0);
Promise.resolve().then(() => {
console.log('Microtask 1');
});
console.log('End');
As node is a sngle threaded the call stack server as synchrounous code execution place and it follows LIFO (Last In, First Out) order. So based on the order the first code that executed is the console.log('Start');
. It will first enter the call stack and gets executed synchronously on the call stack.
The second line of that got executed is setTimeout
. This function will be placed on the call stack by the event loop. Node.js can not execute the setTimeout
in the V8 engine, instead it relies on the NOde.js C++ API to execute it. So the setTimeout
will call the underlying libuv API and will be removed from the call stack. Once the delay specified expires, the event loop will place the callback in the Task Queue.
The third line is now in the stack to be executed, but it is a promise and cannot be resolved in the V8 engine. So it will call the underlying API from libuv. Once the Promise is resolved their callback will be added to the MicroQueue.
The last item on the call stack is now console.log('Start');
. Its a synchronous operation and it will be executed by the V8 engine.
Now we dont have anything in the call stack and the event loop doesnot have to execute from the callstack it will bring the callback from the queue to the callstack. The queue has a priority to pick first from the Microtask then the Macro Task queue or the task queue. As we have the resolved promise call back in the microqueue, the event loop bring the call back and execute it in the callstack. once the micro queue is executed and we dont have anything on the callback . the event loop will check the microqueue if there is more task to pick up but in our case we dont have anything , so it will pick from the macro queue aka the setTimeout
callback. The event loop will bring its callback and execute it in the callstack.
Output:
Start
End
Microtask 1
Macrotask 1
Node.js uses C++ bindings to handle asynchronous operations under the hood. JavaScript code in Node.js interacts with the underlying OS using the libuv library (written in C/C++). Libuv handles tasks like file system operations and networking. When the async tasks are executed they will be running on another thread apart from the main V8 engine thread, which make the javascript non-blocking.
Here’s a deeper look at how Node.js works:
Here’s an example showing how Node.js handles an async file read operation using the file system (fs) module:
const fs = require('fs');
console.log("Before reading file");
fs.readFile('file.txt', 'utf8', (err, data) => {
if (err) throw err;
console.log(data);
});
console.log("After reading file");
The expected output might look like this:
Before reading file
After reading file
(file contents)
Even though the readFile
function appears earlier in the code, its callback only executes after the I/O operation completes.
Node.js, while single-threaded at the JavaScript level, uses C++ to manage threading under the hood. For I/O-bound operations, it uses a thread pool that handles multiple tasks simultaneously without blocking the main thread. The default thread pool size is 4 (but you can configure it), and this is used for non-blocking I/O operations like file reading and network requests.
In simple terms:
Here’s how you can configure the thread pool:
export UV_THREADPOOL_SIZE=8
This will increase the thread pool size to handle more concurrent I/O-bound tasks.
In Node.js, the microtask and macrotask queues are managed slightly differently than in the browser. The event loop in Node.js has six phases:
setTimeout()
and setInterval()
.setImmediate()
callbacks.socket.on('close')
.Microtasks (like promises) are always processed at the end of every event loop iteration before moving on to the next phase.
setImmediate()
in Node.js
console.log('Start');
setImmediate(() => {
console.log('Immediate');
});
setTimeout(() => {
console.log('Timeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('End');
Output:
Start
End
Promise
Immediate
Timeout
The promise (microtask) resolves first, followed by setImmediate
(macrotask), and finally setTimeout
(another macrotask).
Subscribe for weekly newsletters about detailed and abstract concepts, as well as advanced content on .NET and Node.js.