All posts

Node.js — Introduction

The event loop, async patterns, streams, Express, and how Node.js actually handles concurrent requests — a complete introduction with full interview coverage.


JavaScript was born in the browser. For years, that was the only place it lived. You wrote JS to make buttons click and forms validate — nothing more.

Node.js changed that. It took the V8 engine — the same one Chrome uses to run JavaScript — ripped it out of the browser, and dropped it on the server. Suddenly JavaScript could read files, talk to databases, handle HTTP requests, and run as a backend service.

One language, everywhere. That’s the deal.


What Is Node.js?

Node.js is a JavaScript runtime built on Chrome’s V8 engine. It’s not a language, not a framework — it’s an environment that lets JavaScript run outside the browser.

What it adds on top of V8:

  • Access to the file system
  • Network capabilities (HTTP, TCP, UDP)
  • OS interaction (processes, environment variables)
  • A module system
  • A standard library for common tasks
Browser JS                    Node.js
──────────────────────────    ──────────────────────────────
window, document, DOM         No DOM, no window
fetch, localStorage           fs, http, path, os, crypto
Sandboxed (can't touch OS)    Full OS access
Runs in a tab                 Runs as a process

Used for: REST APIs, GraphQL servers, CLI tools, real-time apps, microservices, scripting, build tools (webpack, vite, esbuild are all Node.js).


How Node.js Works — The Event Loop

This is the most important concept in Node.js. Everything else builds on it.

JavaScript is single-threaded — one call stack, one thing happens at a time. So how does Node.js handle thousands of simultaneous connections without crashing?

The Event Loop.

   ┌─────────────────────────────────────┐
   │            Call Stack               │
   │  (your synchronous JS runs here)    │
   └──────────────────┬──────────────────┘

   ┌──────────────────▼──────────────────┐
   │             Event Loop              │
   │  checks: is the call stack empty?   │
   └──────────────────┬──────────────────┘

        ┌─────────────┴─────────────┐
        ▼                           ▼
┌───────────────┐         ┌──────────────────┐
│  Callback     │         │   libuv thread   │
│  Queue        │         │   pool           │
│  (timers,     │         │  (file I/O,      │
│   I/O, etc.)  │         │   DNS, crypto)   │
└───────────────┘         └──────────────────┘

When Node.js hits something slow — reading a file, making an HTTP request, querying a database — it doesn’t wait. It hands the work off to the OS or a thread pool, registers a callback, and keeps running other code. When the slow work finishes, the callback is queued. The Event Loop picks it up and runs it when the call stack is empty.

This is non-blocking I/O. Node.js stays responsive while waiting for slow operations.

// Blocking — nothing else runs until this finishes
const data = fs.readFileSync('file.txt'); // hangs here
console.log(data);

// Non-blocking — registers callback, moves on immediately
fs.readFile('file.txt', (err, data) => {
  console.log(data); // runs later, when the file is ready
});
console.log('this runs first'); // this prints before the file data

What the event loop phases look like:

timers          → setTimeout, setInterval callbacks
pending I/O     → I/O callbacks deferred from previous loop
idle/prepare    → internal use
poll            → retrieve new I/O events, execute callbacks
check           → setImmediate callbacks
close callbacks → socket.on('close', ...) etc.

Modules

Node.js has two module systems. Both exist in the wild.

CommonJS (CJS) — the original

// exporting
const add = (a, b) => a + b;
module.exports = { add };

// or
module.exports = add; // export a single value

// importing
const { add } = require('./math');
const add = require('./math'); // if exported as single value
const path = require('path'); // built-in module
const express = require('express'); // npm package

require() is synchronous and cached — calling it twice for the same module returns the same object.

ES Modules (ESM) — the modern standard

// exporting
export const add = (a, b) => a + b;
export default add; // default export

// importing
import { add } from './math.js'; // note: extension required in ESM
import add from './math.js';
import path from 'path';

To use ESM in Node.js: either use .mjs extension, or add "type": "module" to package.json.

{ "type": "module" }

CJS vs ESM:

CommonJS                    ES Modules
──────────────────────────  ──────────────────────────────
require() — synchronous     import — static, hoisted
Works in all Node versions  Node 12+ (stable 14+)
No file extension needed    .js extension required
__dirname, __filename       import.meta.url instead
Default in Node.js          Default in browsers, modern TS

Most existing Node.js projects (Express, older tooling) use CommonJS. New projects increasingly use ESM. TypeScript compiles to either.


npm and package.json

npm (Node Package Manager) is how you install third-party code and manage your project.

npm init -y              # create package.json with defaults
npm install express      # install a dependency
npm install -D nodemon   # install a dev dependency
npm install              # install all deps listed in package.json
npm uninstall express    # remove a package
npm run <script>         # run a script from package.json
npm list                 # list installed packages

package.json — the blueprint of your project:

{
  "name": "my-app",
  "version": "1.0.0",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js",
    "build": "tsc"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "nodemon": "^3.0.1",
    "typescript": "^5.0.0"
  }
}

dependencies — needed at runtime (your server uses these).
devDependencies — only needed during development (compilers, test runners, hot-reload tools).

package-lock.json — locks the exact version of every package (and every package’s package). Always commit this. It ensures every developer and every CI server installs the exact same dependency tree.

node_modules/ — where installed packages live. Never commit this. It’s reproducible from package.json + package-lock.json with npm install.


The Built-in Modules

Node.js ships with a standard library. No install needed — just require or import.

fs — File System

const fs = require('fs');
const fsPromises = require('fs').promises; // or 'fs/promises'

// Async with callback
fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

// Async with promises (preferred)
const data = await fsPromises.readFile('file.txt', 'utf8');

// Write a file
await fsPromises.writeFile('output.txt', 'Hello, Node.js');

// Append to a file
await fsPromises.appendFile('log.txt', 'New log entry\n');

// Check if a file exists
const exists = await fsPromises.access('file.txt').then(() => true).catch(() => false);

// Read directory
const files = await fsPromises.readdir('./src');

path — Path Manipulation

const path = require('path');

path.join('/users', 'dhaivick', 'docs');    // '/users/dhaivick/docs'
path.resolve('src', 'index.js');            // absolute path from CWD
path.dirname('/users/dhaivick/file.txt');   // '/users/dhaivick'
path.basename('/users/dhaivick/file.txt');  // 'file.txt'
path.extname('file.txt');                   // '.txt'
path.parse('/home/user/file.txt');
// { root: '/', dir: '/home/user', base: 'file.txt', ext: '.txt', name: 'file' }

// __dirname — absolute path of the current file's directory (CJS only)
const fullPath = path.join(__dirname, 'data', 'users.json');

http — HTTP Server

const http = require('http');

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ message: 'Hello, world' }));
});

server.listen(3000, () => console.log('Server running on port 3000'));

You rarely use the raw http module directly — Express or Fastify wrap it. But understanding it clarifies what frameworks do under the hood.

os — Operating System

const os = require('os');

os.platform();   // 'darwin', 'linux', 'win32'
os.cpus();       // array of CPU info
os.totalmem();   // total memory in bytes
os.freemem();    // free memory in bytes
os.homedir();    // '/Users/dhaivick'
os.hostname();   // machine name

crypto — Cryptography

const crypto = require('crypto');

// Hash a password (use bcrypt for real passwords)
const hash = crypto.createHash('sha256').update('mypassword').digest('hex');

// Generate random bytes
const token = crypto.randomBytes(32).toString('hex');

// UUID
const id = crypto.randomUUID(); // 'a4b2c1d0-...'

events — Event Emitter

The backbone of Node.js’s asynchronous model. Many built-in modules (http, stream, fs) extend EventEmitter.

const EventEmitter = require('events');

const emitter = new EventEmitter();

emitter.on('data', (payload) => {
  console.log('Received:', payload);
});

emitter.emit('data', { id: 1, name: 'Dhaivick' });

// Once — fires only the first time
emitter.once('connect', () => console.log('Connected'));

// Remove listener
const handler = (data) => console.log(data);
emitter.on('message', handler);
emitter.off('message', handler);

process — The Running Process

process.env.NODE_ENV       // 'development', 'production', etc.
process.env.PORT           // environment variable
process.argv               // command line arguments array
process.cwd()              // current working directory
process.exit(0)            // exit with code 0 (success)
process.exit(1)            // exit with code 1 (error)

// Catch unhandled errors — prevent crashes
process.on('uncaughtException', (err) => {
  console.error('Unhandled error:', err);
  process.exit(1);
});

process.on('unhandledRejection', (reason) => {
  console.error('Unhandled promise rejection:', reason);
  process.exit(1);
});

Asynchronous Patterns

Node.js started with callbacks. The ecosystem evolved to promises, then async/await. You’ll encounter all three.

Callbacks

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

Error-first callbacks — the convention: the first argument is always the error (or null if none). Always check it.

Callback hell — what happens when you nest callbacks:

fs.readFile('a.txt', 'utf8', (err, a) => {
  fs.readFile('b.txt', 'utf8', (err, b) => {
    fs.readFile('c.txt', 'utf8', (err, c) => {
      // deeply nested, hard to read, error handling is a mess
    });
  });
});

Promises

const fsPromises = require('fs/promises');

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

// Parallel — both start simultaneously
Promise.all([
  fsPromises.readFile('a.txt', 'utf8'),
  fsPromises.readFile('b.txt', 'utf8'),
]).then(([a, b]) => console.log(a, b));

// First to settle
Promise.race([fetchFromServer1(), fetchFromServer2()])
  .then(result => console.log(result));

// All settle regardless of failure
Promise.allSettled([p1, p2, p3])
  .then(results => results.forEach(r => console.log(r.status)));

Async/Await

const readFiles = async () => {
  try {
    const a = await fsPromises.readFile('a.txt', 'utf8');
    const b = await fsPromises.readFile('b.txt', 'utf8');
    console.log(a, b);
  } catch (err) {
    console.error(err);
  }
};

// Parallel with async/await
const readParallel = async () => {
  const [a, b] = await Promise.all([
    fsPromises.readFile('a.txt', 'utf8'),
    fsPromises.readFile('b.txt', 'utf8'),
  ]);
};

Don’t await in a loop when tasks are independent — that’s sequential, not parallel:

// Slow — each waits for the previous
for (const id of ids) {
  const user = await fetchUser(id); // sequential
}

// Fast — all start simultaneously
const users = await Promise.all(ids.map(id => fetchUser(id)));

Streams

Streams handle data in chunks rather than loading everything into memory at once. Essential for large files, HTTP responses, or any data that arrives over time.

Readable  → source of data (file read, HTTP request)
Writable  → destination for data (file write, HTTP response)
Duplex    → both readable and writable (TCP socket)
Transform → duplex that modifies data as it passes through (compression)
const fs = require('fs');

// Without streams — entire file loaded into memory
const data = fs.readFileSync('huge-file.txt'); // bad for large files

// With streams — chunks flow through, minimal memory usage
const readable = fs.createReadStream('huge-file.txt');
const writable = fs.createWriteStream('output.txt');

readable.pipe(writable); // pipe connects readable → writable

Consuming a readable stream:

const stream = fs.createReadStream('file.txt', { encoding: 'utf8' });

stream.on('data', (chunk) => console.log('Chunk:', chunk));
stream.on('end', () => console.log('Done'));
stream.on('error', (err) => console.error(err));

Why streams matter: if a user downloads a 2GB file and you read it all into memory first, your server needs 2GB of RAM per concurrent download. With streams, it needs only a small buffer. Streams also start sending data to the client immediately — the user sees progress faster.


Error Handling

Node.js errors come in two flavours — synchronous throws and asynchronous rejections.

// Synchronous — use try/catch
try {
  const data = JSON.parse(invalidJson);
} catch (err) {
  console.error('Parse failed:', err.message);
}

// Async — try/catch with await
try {
  const data = await fsPromises.readFile('missing.txt', 'utf8');
} catch (err) {
  if (err.code === 'ENOENT') {
    console.error('File not found');
  } else {
    throw err; // re-throw unexpected errors
  }
}

Creating custom errors:

class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.name = 'AppError';
    this.statusCode = statusCode;
  }
}

throw new AppError('User not found', 404);

Common Node.js error codes:

ENOENT    → file or directory not found
EACCES    → permission denied
EADDRINUSE → port already in use
ECONNREFUSED → connection refused
ETIMEDOUT  → connection timed out
EEXIST    → file already exists

Express — Building HTTP Servers

The raw http module works but is low-level. Express is the most widely used Node.js framework — it adds routing, middleware, and request/response helpers on top.

npm install express
const express = require('express');
const app = express();

// Middleware — runs before route handlers
app.use(express.json()); // parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // parse form data

// Routes
app.get('/', (req, res) => {
  res.json({ message: 'Hello, world' });
});

app.get('/users/:id', (req, res) => {
  const { id } = req.params;
  res.json({ id, name: 'Dhaivick' });
});

app.post('/users', (req, res) => {
  const { name, email } = req.body;
  // create user in database...
  res.status(201).json({ id: '1', name, email });
});

app.put('/users/:id', (req, res) => { /* ... */ });
app.delete('/users/:id', (req, res) => { /* ... */ });

app.listen(3000, () => console.log('Server on port 3000'));

The req and res objects:

// Request
req.params      // route params — /users/:id → req.params.id
req.query       // query string — /users?page=2 → req.query.page
req.body        // request body (needs middleware to parse)
req.headers     // request headers
req.method      // 'GET', 'POST', etc.
req.url         // '/users/1'

// Response
res.json(data)              // send JSON
res.send('text')            // send text
res.status(404).json({})    // set status code and send
res.redirect('/login')      // redirect
res.setHeader('key', 'val') // set header

Middleware

Middleware functions are the backbone of Express. A middleware is a function with access to req, res, and next.

Request → middleware1 → middleware2 → route handler → Response
// Middleware signature
const logger = (req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next(); // call next() to pass control to the next middleware
  // not calling next() stops the chain
};

// Apply globally
app.use(logger);

// Apply to a specific route
app.get('/dashboard', authenticate, (req, res) => {
  res.json({ data: 'secret' });
});

// Authentication middleware
const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token' });
  try {
    req.user = verifyToken(token);
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
};

// Error-handling middleware — four parameters, always last
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.statusCode || 500).json({ error: err.message });
});

Order matters. Middleware runs in the order it’s declared. Global middleware must come before route definitions to apply to them.


Environment Variables

Never hardcode secrets. Use environment variables.

# .env file
PORT=3000
DATABASE_URL=postgres://localhost/mydb
JWT_SECRET=supersecretkey
NODE_ENV=development
npm install dotenv
require('dotenv').config(); // load .env into process.env — call this first

const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;

.env goes in .gitignore. You share a .env.example with placeholder values so other developers know what variables to set.


Working with Databases

Node.js doesn’t ship with database drivers — you install them. Two common patterns.

Raw driver (pg for PostgreSQL):

const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const getUsers = async () => {
  const result = await pool.query('SELECT * FROM users WHERE active = $1', [true]);
  return result.rows;
};

const createUser = async (name, email) => {
  const result = await pool.query(
    'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
    [name, email]
  );
  return result.rows[0];
};

ORM (Prisma):

const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

const users = await prisma.user.findMany({ where: { active: true } });

const user = await prisma.user.create({
  data: { name: 'Dhaivick', email: '[email protected]' },
});

await prisma.user.update({
  where: { id: '1' },
  data: { name: 'Updated' },
});

await prisma.user.delete({ where: { id: '1' } });

Child Processes

Node.js can spawn system commands and other processes.

const { exec, spawn, execFile } = require('child_process');

// exec — runs a command in a shell, buffers output
exec('ls -la', (err, stdout, stderr) => {
  if (err) throw err;
  console.log(stdout);
});

// spawn — streams output, better for long-running processes
const child = spawn('node', ['worker.js']);

child.stdout.on('data', (data) => console.log(data.toString()));
child.stderr.on('data', (data) => console.error(data.toString()));
child.on('close', (code) => console.log(`Exit code: ${code}`));

// Promisified exec
const { promisify } = require('util');
const execAsync = promisify(exec);
const { stdout } = await execAsync('git log --oneline -5');

Worker Threads

The Event Loop is single-threaded. CPU-heavy work (image processing, encryption, parsing large files) blocks it — while you’re computing, nothing else runs.

Worker Threads let you run JavaScript in parallel threads.

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

// main.js
if (isMainThread) {
  const worker = new Worker('./worker.js', {
    workerData: { numbers: [1, 2, 3, 4, 5] },
  });

  worker.on('message', (result) => console.log('Result:', result));
  worker.on('error', (err) => console.error(err));
}

// worker.js
const { workerData, parentPort } = require('worker_threads');

const sum = workerData.numbers.reduce((a, b) => a + b, 0);
parentPort.postMessage(sum);

Workers don’t share memory by default — they communicate via message passing. Use SharedArrayBuffer for shared memory when needed.


Interview Essentials

How the Event Loop actually works

The call stack runs synchronous code. When it hits async work (I/O, timers), Node.js hands it to libuv — a C library that manages the OS-level async operations and a thread pool for things the OS can’t do asynchronously (like file I/O on some systems). When the work completes, the callback goes into the appropriate queue. The Event Loop processes queues in order — microtasks (Promises, queueMicrotask) run before macrotasks (setTimeout, setImmediate, I/O callbacks) in each iteration.

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// Output: 1, 4, 3, 2
// Why: sync runs first (1, 4), then microtasks (3), then macrotasks (2)

This is one of the most common Node.js interview questions. The answer: microtasks (Promises) always run before macrotasks (setTimeout) in the same iteration.

setTimeout(fn, 0) vs setImmediate vs process.nextTick

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));

// Output: nextTick, timeout or immediate (order of timeout/immediate varies), immediate or timeout
process.nextTick   → runs after current operation, before I/O callbacks — highest priority
Promise.then       → microtask queue, runs before macrotasks
setImmediate       → check phase of the event loop (after I/O)
setTimeout(fn, 0)  → timers phase — not guaranteed to be before setImmediate

process.nextTick runs before any I/O, before timers, before everything in the next tick. Use it sparingly — overuse starves the event loop.

Blocking vs non-blocking — why it matters

// This blocks the entire server for every request
app.get('/hash', (req, res) => {
  const hash = crypto.pbkdf2Sync('password', 'salt', 100000, 64, 'sha512'); // blocks!
  res.json({ hash });
});

// This doesn't block — callback fires when done
app.get('/hash', (req, res) => {
  crypto.pbkdf2('password', 'salt', 100000, 64, 'sha512', (err, hash) => {
    res.json({ hash: hash.toString('hex') });
  });
});

CPU-bound work blocks the Event Loop. I/O-bound work (database queries, file reads, HTTP requests) is non-blocking and handled by libuv. For CPU-heavy operations, use Worker Threads or offload to a separate service.

Streams vs buffers

Loading a 500MB file into memory with fs.readFileSync or fs.readFile requires 500MB of RAM. Streaming it with createReadStream uses only the size of one chunk (default 64KB) at a time. Always use streams for large files and HTTP responses.

CommonJS vs ESM — the practical difference

CJS require() is synchronous and dynamic — you can call it inside an if block. ESM import is static — it must be at the top level and is resolved before any code runs. This lets bundlers do tree-shaking on ESM but not CJS. You can import CJS from ESM but not the reverse without workarounds.

The cluster module — scaling Node.js

Node.js runs on one CPU core. The cluster module lets you fork multiple worker processes that share the same port — one per CPU core.

const cluster = require('cluster');
const os = require('os');

if (cluster.isPrimary) {
  const cpuCount = os.cpus().length;
  for (let i = 0; i < cpuCount; i++) {
    cluster.fork(); // spawn a worker per CPU
  }
  cluster.on('exit', (worker) => {
    console.log(`Worker ${worker.process.pid} died — restarting`);
    cluster.fork();
  });
} else {
  // each worker runs the server
  const app = require('./app');
  app.listen(3000);
}

In practice, tools like PM2 handle clustering without writing it manually: pm2 start app.js -i max.

Memory leaks — common causes

Global variable accumulation  → data added to a global array, never removed
Unclosed streams               → readable stream with no 'end' handler, never consumed
Event listener buildup         → emitter.on() called repeatedly, no emitter.off()
Closure holding references     → inner function keeps outer scope alive

Use --inspect flag to attach Chrome DevTools to a Node.js process for heap snapshots and profiling.

util.promisify

Converts callback-style functions to return promises:

const { promisify } = require('util');
const readFile = promisify(fs.readFile);

const data = await readFile('file.txt', 'utf8'); // no callback needed

Most modern Node.js APIs already have promise versions (fs/promises, dns/promises), but promisify is useful for legacy code and third-party callbacks.


The Mental Model

Node.js is a loop waiting for things to happen. Your code registers what should happen when — a file finishes reading, a request comes in, a timer fires. Node.js runs that loop, picks up completed work, and calls your handlers. Between handlers, it’s doing nothing except waiting.

This makes Node.js extremely efficient for I/O-heavy workloads — APIs, proxies, real-time connections. It makes it a poor fit for CPU-heavy workloads — video encoding, ML inference, cryptographic mining — unless you push that work to threads or separate processes.

Write non-blocking code. Handle your errors. Keep your callbacks, promises, and async/await consistent. The rest is just Node’s standard library and whatever npm packages you choose to trust.