Intermediate~20 min read

Node.js

The Node.js runtime and event loop, non-blocking I/O, ESM modules, npm and semver, built-in modules, streams and buffers, EventEmitter, environment and process, async error handling, the node:test runner, and worker threads.

ESMEvent LoopStreamsnpm

What is Node.js?

Node.js is a JavaScript runtime built on Chrome's V8 engine. It runs JavaScript outside the browser and is designed around non-blocking, event-driven I/O. A single main thread handles many concurrent connections by never waiting on slow operations — instead, it registers callbacks and continues, processing results as they arrive.

This model makes Node.js excellent for I/O-heavy workloads (APIs, real-time servers, proxies) where most time is spent waiting on the network or disk rather than crunching numbers. CPU-bound work should be offloaded to worker threads or separate services.

The Event Loop

The event loop is the heart of Node's concurrency. JavaScript itself is single-threaded, but Node delegates I/O to the OS and libuv's thread pool. When an operation completes, its callback is queued and run on the main thread. The loop cycles through ordered phases, each with its own callback queue.

PhaseHandles
timersCallbacks from setTimeout / setInterval
pending callbacksDeferred I/O callbacks (e.g. some TCP errors)
pollRetrieves new I/O events, runs most I/O callbacks
checkCallbacks from setImmediate
closeClose events, e.g. socket.on('close')

Between every phase and every callback, Node drains two microtask queues: process.nextTick() (highest priority) and resolved Promise jobs. Microtasks always run before the loop moves on, which is why a Promise .then fires before a setTimeout(fn, 0).

javascript
console.log('1: sync');
setTimeout(() => console.log('4: timer'), 0);
setImmediate(() => console.log('5: immediate'));
Promise.resolve().then(() => console.log('3: microtask'));
process.nextTick(() => console.log('2: nextTick'));
// Order: 1 → 2 → 3 → 4/5 (timer vs immediate order varies at top level)

Don't block the loop

A long synchronous loop or a giant JSON.parse freezes the entire process — no other request is served until it finishes. Keep per-callback work small, stream large payloads, and push heavy computation to worker threads.

ESM Modules

Modern Node uses ECMAScript Modules (ESM) — the standard import / export syntax — rather than the legacy CommonJS require. Enable ESM by adding "type": "module" to package.json, or by using the .mjs extension.

javascript
// math.js
export function add(a, b) { return a + b; }
export const PI = 3.14159;
export default function multiply(a, b) { return a * b; }

// app.js — named + default imports
import multiply, { add, PI } from './math.js';   // note the .js extension
import * as math from './math.js';                // namespace import
import { readFile } from 'node:fs/promises';      // built-ins: node: prefix

// Top-level await works in ESM — no wrapper async function needed
const config = JSON.parse(await readFile('./config.json', 'utf8'));
console.log(add(2, 3), config.port);

ESM imports are static and hoisted — resolved before execution — and file specifiers need the full extension. Prefix built-in modules with node: to make the origin explicit and avoid clashing with npm packages. For conditional loading, use the async import() function.

ESM vs CommonJS

AspectESMCommonJS
Syntaximport / exportrequire / module.exports
LoadingStatic, async, hoistedDynamic, synchronous
Top-level awaitSupportedNot supported
File dirnameimport.meta.dirname__dirname / __filename
Enable via"type":"module" or .mjsDefault or .cjs

npm, package.json & semver

package.json is the manifest for a Node project — it declares metadata, dependencies, and scripts. package-lock.json pins the exact resolved versions so installs are reproducible. Use npm ci in CI to install strictly from the lockfile.

json
{
  "name": "my-service",
  "version": "1.4.2",
  "type": "module",
  "engines": { "node": ">=20" },
  "scripts": {
    "start": "node src/server.js",
    "test": "node --test",
    "dev": "node --watch src/server.js"
  },
  "dependencies": { "express": "^5.0.0" },
  "devDependencies": { "eslint": "~9.10.0" }
}

Semantic versioning is MAJOR.MINOR.PATCH: major = breaking change, minor = backward-compatible feature, patch = backward-compatible bug fix. Version ranges control how much npm may upgrade on install:

RangeMeaningMatches for 1.4.2
^1.4.2Compatible: same major>=1.4.2 <2.0.0
~1.4.2Approximate: same minor>=1.4.2 <1.5.0
1.4.2Exact pin1.4.2 only
*Any versionAnything (avoid)

Built-in Modules

Node ships a rich standard library. Prefer the promise-based APIs (node:fs/promises) over callback-style, and always use node:path to build paths so code works cross-platform.

javascript
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';

// fs/promises — async file I/O without blocking the loop
const dir = path.join(import.meta.dirname, 'data');
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'out.txt'), 'hello');
const text = await readFile(path.join(dir, 'out.txt'), 'utf8');

// os — system info
console.log(os.cpus().length, os.freemem(), os.platform());

// crypto — hashing, random IDs, HMAC
const id = crypto.randomUUID();
const hash = crypto.createHash('sha256').update(text).digest('hex');

A minimal HTTP server

javascript
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  if (req.url === '/health' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok' }));
    return;
  }
  res.writeHead(404).end('Not Found');
});

server.listen(3000, () => console.log('listening on :3000'));

Streams & Buffers

A Buffer is a fixed-length chunk of raw binary memory outside V8's heap — the way Node represents bytes. Streams process data piece by piece instead of loading it all into memory, which is essential for large files or network data. The four types are Readable, Writable, Duplex, and Transform.

javascript
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createGzip } from 'node:zlib';

// Stream a large file through gzip to disk — constant memory usage.
// pipeline handles backpressure and cleans up on error automatically.
await pipeline(
  createReadStream('big.log'),
  createGzip(),
  createWriteStream('big.log.gz')
);

// Buffers represent binary data
const buf = Buffer.from('Node', 'utf8');
console.log(buf.length, buf.toString('base64')); // 4 Tm9kZQ==

Backpressure

When a fast source outpaces a slow destination, streams signal backpressure so the source pauses until the buffer drains. Using pipeline() (or .pipe()) handles this for you; manual write() loops must respect the boolean return value.

EventEmitter

The EventEmitter class is the foundation of Node's event-driven design — streams, HTTP servers, and processes all extend it. Objects emit named events and any number of listeners react to them.

javascript
import { EventEmitter } from 'node:events';

class Job extends EventEmitter {
  run() {
    this.emit('start');
    setTimeout(() => this.emit('done', { rows: 42 }), 10);
  }
}

const job = new Job();
job.on('start', () => console.log('started'));
job.once('done', (result) => console.log('finished', result.rows));
job.run();

// Always attach an 'error' listener — an unhandled 'error' event throws.
job.on('error', (err) => console.error(err));

Environment Variables & process

The global process object exposes the running environment: command-line arguments, environment variables, signals, and exit control. Read configuration from process.env — never hardcode secrets. Modern Node can load a .env file natively with --env-file.

javascript
// node --env-file=.env server.js
const PORT = Number(process.env.PORT ?? 3000);
const DB_URL = process.env.DATABASE_URL;
if (!DB_URL) { console.error('DATABASE_URL required'); process.exit(1); }

console.log(process.argv.slice(2));   // CLI args after the script
console.log(process.platform, process.version);

// Graceful shutdown — close connections before exiting
process.on('SIGTERM', async () => {
  await closeConnections();
  process.exit(0);
});

Error Handling in Async Code

With Promises and async/await, wrap awaited calls in try/catch. A rejected Promise that no one handles becomes an unhandled rejection, which crashes the process in modern Node — as it should. Handle errors close to where they occur; only use global handlers to log and exit cleanly.

javascript
async function loadUser(id) {
  try {
    const res = await fetch(`https://api.example.com/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    // Wrap with context, then rethrow or return a fallback
    throw new Error(`loadUser(${id}) failed`, { cause: err });
  }
}

// Run independent async work concurrently and fail fast
const [a, b] = await Promise.all([loadUser(1), loadUser(2)]);

// Last-resort safety net (log + exit; do not swallow)
process.on('unhandledRejection', (err) => {
  console.error('unhandled rejection', err);
  process.exit(1);
});

The Built-in Test Runner

Node includes a test runner via the node:test module and the node --test command — no external framework required for many projects. Assertions come from node:assert.

javascript
// math.test.js — run with: node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { add } from './math.js';

test('add sums two numbers', () => {
  assert.equal(add(2, 3), 5);
});

test('rejects on bad input', async () => {
  await assert.rejects(() => loadUser('nope'));
});

Worker Threads (brief)

For CPU-intensive work — image processing, cryptography, parsing — use node:worker_threads to run JavaScript on a separate thread with its own event loop, avoiding blocking the main loop. Threads communicate by message passing (or shared memory via SharedArrayBuffer).

javascript
import { Worker } from 'node:worker_threads';

const worker = new Worker('./heavy-task.js', { workerData: { n: 40 } });
worker.on('message', (result) => console.log('result', result));
worker.on('error', (err) => console.error(err));
worker.on('exit', (code) => console.log('worker exited', code));

Practice Exercises

  1. Predict the console output order of a script mixing setTimeout, setImmediate, process.nextTick, and a resolved Promise, then run it to verify.
  2. Convert a CommonJS module (require / module.exports) to ESM, add "type":"module", and use top-level await to load a JSON config.
  3. Write a stream pipeline that reads a large CSV, uppercases each line in a Transform stream, and writes the result — verify memory stays constant with a huge input.
  4. Build a small EventEmitter-based job queue that emits start, progress, and done events.
  5. Write a node:test suite for a function, including an async case with assert.rejects, and run it with node --test.
  6. Offload a Fibonacci or SHA-256 loop to a worker thread and confirm the main HTTP server stays responsive during the computation.

Section navigation