Understanding Node.js: Basics and How It Works

Understanding Node.js: Basics and How It Works

Dipesh Chaulagain
Dipesh Chaulagain

A. What is Node.Js

  • An asynchronous event-driven JavaScript runtime designed to build scalable network applications.
  • Runs on v8 JavaScript engine.
  • Almost no functions in Node.js directly performs I/0, so the process never blocks except when the I/O is performed using synchronous methods of Node.js standard library.
  • Scalable for backend system heavy on network based I/0 operations. Many connections can be handled concurrently for the code example below :
const http = require('node:http');
const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World');
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

B. Blocking And Non Blocking

Blocking Code

When blocking synchronous code executes and occupies the call stack (e.g., a long-running computation or synchronous file read using fs.readFileSync()), it prevents further code execution until it completes. The event loop remains active, but it waits until the call stack becomes empty to retrieve and process tasks/callbacks from the event queue. If the call stack is occupied by blocking synchronous code, the event loop will not process the event queue until the call stack is cleared.

const fs = require('node:fs');
const data = fs.readFileSync('/file.md'); // blocks here until file is read
console.log(data);
moreWork(); // will run after console.log
Non Blocking Code

Asynchronous operations like file I/O using fs.readFile(), network requests, timers, and other asynchronous tasks do not block the event queue. When these asynchronous operations complete, their corresponding callbacks are placed in the event queue regardless of whether the call stack is blocked by synchronous code and are processed when call stack is cleared.

const fs = require('node:fs');
fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
  console.log(data);
});
moreWork(); // will run before console.log

==C. Node.js Architecture==

alt text for screen readers.

V8 Engine

  1. Memory Heap for management of memory allocation for objects, variables, and closures.

  2. The call stack in V8 is a Last-In, First-Out (LIFO) data structure that keeps track of the currently executing functions or frames during the JavaScript code execution process.

  3. The Garbage Collector in V8 is a memory management component responsible for identifying and deallocating unused or unreferenced memory objects to free up memory for reuse.

  4. The Compiler in V8 is responsible for translating JavaScript code into optimized machine code, utilizing Just-In-Time (JIT) compilation techniques to enhance execution performance.

Node.js Bindings (Node API)

  1. Node.js C++ Addons or Node API: Facilitates interaction between JavaScript and underlying C/C++ code in Node.js applications.

  2. N-API: Provides a stable interface for creating native addons compatible across different Node.js versions, ensuring resilience to version changes.

  3. Asynchronous Worker Threads: Utilizes a thread pool for running CPU-intensive tasks, enhancing performance through parallel execution.

  4. Buffer and Stream APIs: Enables efficient handling of binary data and streaming I/O operations, particularly useful for managing large datasets.

  5. C++ Integration: Allows developers to create custom native addons in C++ to extend Node.js functionalities, accessing OS-specific features and hardware-level capabilities.

Node.js bindings expand the capabilities of Node.js applications by enabling access to system-specific functionalities, enhancing performance with native code, and providing compatibility across different Node.js versions.

Event Queue and Event Loop

  1. Asynchronous Operations and Callbacks: Asynchronous operations like file I/O, network requests, timers, and event-driven callbacks are processed asynchronously in Node.js. When these operations complete or when events occur, their corresponding callbacks are placed in the event queue.

  2. Non-Blocking Nature: The event queue operates independently of the call stack and remains active, continuously receiving completed asynchronous tasks’ callbacks, even if the call stack is busy executing synchronous code.

  3. First-In, First-Out (FIFO) Order: The event queue follows a FIFO (First-In, First-Out) order. The first callback/event that arrives in the queue is the first one to be processed by the event loop once the call stack is empty.

  4. Event Loop Interaction: The event loop continuously checks the call stack. If the call stack is empty, the event loop fetches tasks/callbacks from the event queue and pushes them onto the call stack for execution.

  5. Handling Asynchronous Tasks: When asynchronous tasks (like file reading, network requests) complete, their corresponding callbacks are placed in the event queue. This allows Node.js to handle these tasks asynchronously without blocking the main thread.

How Event Loop Works - Emulating Event Loop

//created as soon as program starts running(myFile.js)
const pendingTimers = [];
const pendingOsTasks = [];
const pendingOperations = [];

function shouldContinue() {
  //check one: Any pending setTimeout, setInterval, setImmediate
  //check two: Any pending OS tasks? (like server listening to a HTTP requests,
  //resolving dns etc)
  //check three: Any pending long running operations? (Like reading a file)
  return pendingTimers.length || pendingOSTasks.length || pendingOperations.length;
}

//event loop, executes in one 'tick'
while (shouldContinue()) {
  // 1) node looks at pendingTimers and sees if any function
  // are ready to be called, if yes execute the callback
  // 2) node looks at pendingOSTasks and pendingOperations, if yes execute the
  // relevant callbacks
  // 3) Pause execution. Continue when...
  // - a new pendingOSTask is done
  // - a new pendingOperation is done
  // - a timer is about to complete
  // 4) Look at pendingTimers. Call any setImmediate
  // 5) Handle any 'close' events
}

Worker Threads

In Node.js, worker threads are a feature that allows developers to run JavaScript code in parallel, taking advantage of multi-core systems and performing CPU-intensive tasks without blocking the main event loop. Worker threads enable the execution of JavaScript code in separate threads, providing a way to perform concurrent operations and parallelize tasks efficiently

  1. Parallel Execution: Worker threads allow concurrent execution of JavaScript code in separate threads, running in parallel to the main thread of the Node.js application.

  2. CPU-Intensive Operations: They are suitable for performing CPU-intensive tasks such as heavy computations, image processing, cryptographic operations, or tasks requiring significant processing power.

  3. Separate Context: Each worker thread has its own JavaScript context, isolated from the main application thread. They share memory by passing messages and can’t directly access each other’s variables.

  4. Communication with Main Thread: Worker threads communicate with the main thread through an inter-thread messaging system. They exchange data using a messaging API to send and receive messages asynchronously.

  5. Thread Pool: Node.js manages a pool of threads to handle worker threads. By default, the number of threads in the pool matches the number of CPU cores, but developers can configure it based on requirements.

const { Worker, isMainThread, parentPort } = require('worker_threads');

if (isMainThread) {
  // Main thread
  const worker = new Worker(__filename); // Creating a new worker thread with the same file
  worker.on('message', (message) => {
    console.log('Received message from worker:', message);
  });
  worker.postMessage('Start worker'); // Sending a message to the worker thread
} else {
  // Worker thread
  parentPort.on('message', (message) => {
    console.log('Received message from main thread:', message);
    parentPort.postMessage('Worker is processing'); // Sending a message back to the main thread
  });
}

In this example:

  • The main thread creates a new worker thread using Worker constructor.
  • The main thread communicates with the worker thread by sending a message using postMessage.
  • The worker thread listens for messages from the main thread using parentPort.on and replies using parentPort.postMessage.