What is Node.js?

Most developers who use Node.js regularly have a working mental model of it without ever having looked at how it actually operates. You know it runs JavaScript on the server, you know it is fast for certain workloads, and you have probably heard that it uses an event loop. This article goes one level deeper, explaining the architectural decisions that make Node.js behave the way it does, and why those decisions matter for the code you write.


Single process, non-blocking architecture

Traditional web servers handle concurrent requests by spawning a new thread for each one. Threads consume memory and have a cost associated with creating and switching between them. Under heavy load, a thread-per-request model hits limits quickly.

Node.js takes a fundamentally different approach. A Node.js application runs in a single process with a single thread. Rather than creating new threads to handle concurrent work, it uses a non-blocking, event-driven model. This is the architectural decision that shapes almost everything else about how Node.js works.


Blocking vs non-blocking I/O

The distinction between blocking and non-blocking is central to understanding Node.js.

In a blocking model, when your code makes an I/O request (reading a file, querying a database, making a network call), execution stops and waits for the result before continuing. The thread is occupied and cannot do anything else in the meantime.

In a non-blocking model, the I/O request is initiated and control returns immediately to the calling code. When the result is ready, a callback or promise resolves to handle it. The thread is free to continue processing other work while waiting.

Node.js is built on non-blocking I/O. Almost all I/O operations in Node.js provide asynchronous, non-blocking versions. Synchronous blocking versions exist in some cases (for example, fs.readFileSync()), but they are intended for specific use cases such as startup scripts, not for code that runs inside request handlers.


libuv and the event loop

The mechanism that makes non-blocking I/O possible in Node.js is libuv, a C library that provides an abstraction layer for asynchronous network and file system operations across different operating systems. libuv handles the underlying I/O work and notifies Node.js when results are ready.

The event loop is the mechanism that coordinates this. It runs continuously, checking for pending I/O events, timers, and callbacks, and processing them in sequence. Each full pass through the event loop is called a tick.

The event loop processes work in phases. A simplified version of those phases, in order, is:

  • Timers — executes callbacks scheduled by setTimeout() and setInterval()
  • I/O callbacks — handles completed I/O operations
  • Poll — retrieves new I/O events and executes their callbacks
  • Check — executes setImmediate() callbacks
  • Close callbacks — handles close events such as socket.on('close', ...)

Between each phase, Node.js checks for process.nextTick() callbacks and microtasks (resolved promises) and processes those first before moving to the next phase.


process.nextTick() and setImmediate()

These two functions are a common source of confusion, and the Node.js documentation acknowledges that the naming is misleading.

process.nextTick() schedules a callback to run after the current operation completes, before the event loop moves to its next phase. It fires before any I/O events and before setImmediate(). Use it when you need to ensure a callback runs at the end of the current operation but before anything else in the event loop.

setImmediate() schedules a callback to run in the check phase of the current event loop iteration, after I/O callbacks. Despite the name, it does not run immediately in an absolute sense.

One behaviour that catches developers out: if both setImmediate() and setTimeout(() => {}, 0) are called at the top level of a script (outside of any I/O callback), the order of execution is not guaranteed, because the event loop may or may not have started by the time the timer fires. Inside an I/O callback, setImmediate() will always run before setTimeout().


Asynchronous patterns

Node.js has supported three successive patterns for handling asynchronous code, each an improvement on the previous.

Callbacks

The original Node.js pattern. A function is passed as an argument and called when the asynchronous operation completes. By convention, the first argument of a Node.js callback is always an error object (or null if there was no error), and the result follows.

fs.readFile('file.txt', (err, data) => {
  if (err) {
    return console.error(err);
  }
  console.log(data);
});

The return before console.error(err) is important. It exits the callback function so that the success code below does not also run. This is a common pattern worth internalising.

Callbacks work but become difficult to manage when multiple asynchronous operations depend on each other, leading to deeply nested code sometimes called callback hell.

Promises

Introduced in ES6, Promises provide a cleaner way to handle asynchronous operations and chain them without nesting. Many Node.js APIs now provide promise-based alternatives alongside their callback versions.

fsPromises.readFile('file.txt')
  .then(data => console.log(data))
  .catch(err => console.error(err));

Async/Await

Introduced in ES2017, async/await is syntactic sugar over Promises that allows asynchronous code to be written in a style that reads like synchronous code. It is now the standard approach for most Node.js code.

async function readFile() {
  try {
    const data = await fsPromises.readFile('file.txt');
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

Working with the file system

Node.js provides the fs module for file system operations. Each operation is available in three forms: asynchronous with a callback, asynchronous with a promise, and synchronous. The asynchronous versions are preferred in most cases.

Reading files

// Callback
fs.readFile('file.txt', (err, data) => { ... });

// Promise
fsPromises.readFile('file.txt').then(...);

// Synchronous (use sparingly)
const data = fs.readFileSync('file.txt');

All three methods read the entire file into memory before returning the data. For large files, it is more efficient to use streams, which process the file in chunks without loading it all at once.

Inspecting files

fs.stat('file.txt', (err, stats) => {
  stats.isFile();        // true
  stats.isDirectory();   // false
  stats.isSymbolicLink(); // false
  stats.size;            // size in bytes
});

Writing and appending files

// Write (overwrites by default)
fs.writeFile('file.txt', data, (err) => { ... });

// Append
fs.appendFile('file.txt', data, (err) => { ... });

writeFile() accepts a flags parameter that can be used to control behaviour, such as preventing an existing file from being overwritten.


HTTP as a first-class citizen

HTTP is built into Node.js as a core module rather than an add-on. You can create a basic HTTP server with just a few lines of native Node.js code. In practice, most developers use a framework like Express or Fastify on top of this, but it is worth knowing that the HTTP handling is native to the runtime rather than delegated to an external dependency.


Further reading


Posted

in

, ,

by

Tags: