Learn Node.js and Express in a Single Post: Complete Tutorial From the Event Loop to Middleware and Production

Node.js is JavaScript on the server: it takes the V8 engine from Chrome, adds non-blocking I/O via an event loop, and lets you write networked backends in the same language as the frontend. Express is the minimal web framework most Node servers are built on. Together they’re the default entry point to backend JavaScript. This single post teaches both in five stages, with hand-drawn diagrams and runnable snippets.

Learning Roadmap

Node.js + Express Roadmap

The roadmap moves from the runtime model (Stage 1), through modules (Stage 2), async (Stage 3), Express (Stage 4), and production (Stage 5). You’ll want solid JavaScript first — Node is JS.


Stage 1 — The Runtime

What Node is

Node is a runtime, not a language: it runs JavaScript using the V8 engine (the same one in Chrome), plus a set of C++ bindings for I/O (files, network, crypto) and libuv, the library that provides the event loop and a thread pool.

Node.js Architecture

The event loop and the single thread

Your JavaScript runs on one thread. The event loop keeps that thread busy with I/O callbacks: when a network request arrives, or a file read completes, libuv notifies the loop, which runs your callback. Because the loop never blocks on I/O, a single thread can handle thousands of concurrent connections.

The hard rule: never block the event loop. A CPU-bound loop (while(true){}), a synchronous file read (fs.readFileSync of a big file), or a heavy computation freezes every connection, not just the one doing the work.

The libuv thread pool

Some operations can’t be made non-blocking at the OS level (file I/O on some platforms, crypto.pbkdf2, DNS lookups). Node offloads these to a thread pool (default 4 threads, configurable via UV_THREADPOOL_SIZE) so the event loop keeps running. Your callback still runs on the single main thread when the work completes.

Pitfall: The thread pool is not parallelism for your JS — it’s for the C++ I/O work. If you need real parallel CPU work in JS, use worker threads (worker_threads) or a child process; the thread pool won’t help you.


Stage 2 — Modules

CommonJS vs ES Modules

Node historically used CommonJS (require/module.exports); modern Node also supports ES Modules (import/export). Use ESM for new code; CommonJS still works for legacy.

Modules: CommonJS vs ESM + npm

// CommonJS (legacy)
const fs = require('fs');
function add(a, b) { return a + b; }
module.exports = { add };
// const { add } = require('./math');

// ES Modules (modern) — package.json: "type": "module"
import fs from 'fs';
export function add(a, b) { return a + b; }
// import { add } from './math';
  CommonJS ESM
Syntax require / module.exports import / export
Resolution runtime, synchronous static, hoisted
Extension .cjs or default .mjs or "type":"module"
Top-level await no yes
Tree-shaking no yes

npm and package.json

{
  "name": "my-api",
  "version": "1.0.0",
  "type": "module",
  "scripts": { "start": "node index.js", "dev": "nodemon index.js", "test": "vitest" },
  "dependencies": { "express": "^4.19.0", "pg": "^8.11.0" },
  "devDependencies": { "nodemon": "^3.1.0", "vitest": "^2.0.0" },
  "engines": { "node": ">=20" }
}
npm install express            # add a runtime dep
npm install --save-dev nodemon # add a dev dep
npm install                    # install everything from package.json + lockfile
npm run dev                    # run the "dev" script

node_modules/ holds installed packages; package-lock.json pins exact versions for reproducibility (commit the lockfile). For speed, use pnpm (dedupes via symlinks) or Bun.

Pitfall: npm install <pkg> without -D puts a build tool into dependencies, shipping it to production. Use --save-dev / -D for anything only needed at build/test time.


Stage 3 — Async

Callbacks -> promises -> async/await

Node’s async story evolved: callbacks (error-first) -> promises -> async/await. Use async/await; it reads top-to-bottom like sync code but never blocks.

// callback (legacy, error-first)
fs.readFile('f.txt', (err, data) => { if (err) return handle(err); use(data); });

// promise + async/await (modern)
import { readFile } from 'node:fs/promises';
async function load() {
  try {
    const data = await readFile('f.txt', 'utf8');  // suspends, doesn't block
    return JSON.parse(data);
  } catch (err) { handle(err); }
}

Always handle errors — an unhandled promise rejection crashes the process (since Node 15). Wrap await in try/catch or attach .catch().

Streams

Streams process data in chunks as it arrives, instead of buffering it all in memory — essential for large files, uploads, or piping:

import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';

// pipe a file through a transform to stdout
await pipeline(createReadStream('big.log'), process.stdout);

// HTTP response is a writable stream
res.write('chunk 1\n'); res.write('chunk 2\n'); res.end('done\n');

Use stream.pipeline (not .pipe()) so errors propagate and resources clean up.

Pitfall: .pipe() doesn’t forward errors — one stream can fail silently while the other keeps going. Use pipeline() from stream/promises.


Stage 4 — Express

Express is a minimal web framework: it routes HTTP requests to handler functions, with a middleware pipeline in between.

A minimal server

import express from 'express';
const app = express();

app.use(express.json());                  // body parser middleware

app.get('/', (req, res) => res.json({ ok: true }));

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

app.listen(3000, () => console.log('http://localhost:3000'));

The middleware pipeline

Express: Request -> Middleware -> Route -> Response

Every request flows through a chain of middleware functions, each getting (req, res, next) and calling next() to pass control onward. One handler eventually sends the response.

// a logging middleware
app.use((req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();                          // pass control to the next middleware/route
});

// order matters: this runs before routes defined after it
app.get('/users', authMiddleware, getUsers);   // route-specific middleware

Request and response objects

  • req.method, req.url, req.path, req.query, req.params
  • req.headers, req.get('Authorization')
  • req.body (after express.json())
  • res.json(obj), res.send(str), res.status(404).send(...), res.redirect(url)

Error handling

A middleware with four arguments (err, req, res, next) is an error handler. Define it last, after all routes:

app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({ error: err.message });
});

Pass errors to it with next(err) or by throwing in an async handler (wrap async routes in a helper — an unhandled rejection from a route handler won’t reach Express automatically):

// wrap async handlers so rejections reach the error middleware
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', wrap(async (req, res) => {
  const user = await db.findUser(req.params.id);
  if (!user) { const e = new Error('not found'); e.status = 404; throw e; }
  res.json(user);
}));

Pitfall: A thrown error in an async route handler, unhandled, becomes an unhandled rejection — not an Express error. Use express-async-errors or the wrap helper so it reaches your error middleware.

Routers

For larger apps, split routes into Router modules and mount them:

import { Router } from 'express';
const users = Router();
users.get('/', listUsers);
users.get('/:id', getUser);
app.use('/users', users);   // mount at /users

Stage 5 — Production

Clustering

A single Node process uses one CPU core. The cluster module (or PM2) forks one worker per core, all sharing a port — this is how you use a multi-core machine:

import cluster from 'node:cluster';
import os from 'node:os';
if (cluster.isPrimary) {
  for (let i = 0; i < os.cpus().length; i++) cluster.fork();
} else {
  // each worker runs the server
  app.listen(3000);
}

In practice, use PM2 (pm2 start app.js -i max) or run behind a container orchestrator with one process per container — don’t hand-roll clustering in production.

Testing

import { test, expect } from 'vitest';
import request from 'supertest';
import app from './app.js';

test('GET / returns ok', async () => {
  const res = await request(app).get('/');
  expect(res.status).toBe(200);
  expect(res.body).toEqual({ ok: true });
});

Vitest (unit/integration) + supertest (HTTP) is the standard combo. For e2e, Playwright drives a real browser.

Deploy and security

  • PM2 for process management on a VM; Docker + your orchestrator for containerized deploys.
  • Security headers: use helmet. CORS: use cors. Rate limiting: express-rate-limit. Input validation: zod or joi.
  • Never trust req.body without validation; never interpolate user input into SQL/shell (injection).
  • Set NODE_ENV=production (faster V8 optimizations, less verbose errors).

Databases

  • SQL: pg (raw), Prisma (typed ORM), Drizzle (typed, lightweight).
  • MongoDB: Mongoose.
  • Cache: ioredis / node-redis.

The ecosystem

Node Toolchain + Ecosystem

Concern Tool
Runtime + packages node, npm, pnpm, nvm
Dev nodemon, tsx, vitest, eslint/prettier
Frameworks Express (minimal), Fastify (fast + schema), Hono (edge), NestJS (structured DI)
DBs + deploy pg/Prisma, Mongoose, Redis, PM2/Docker

Quick-Start Checklist

  1. Install Node (LTS) via nvm or the installer; verify with node -v.
  2. Init a project: npm init, install Express, add a "type": "module" + a dev script.
  3. Write a 10-line server with one GET route; node index.js and hit it with curl.
  4. Add express.json() and a POST route; test with curl -d.
  5. Add a logging middleware + an error-handling middleware (four-arg).
  6. Use async/await with try/catch (or wrap async routes) for DB calls.
  7. Validate input with Zod before it touches your DB.
  8. Add helmet, cors, express-rate-limit for baseline security.
  9. Test with Vitest + supertest in CI.
  10. Run behind PM2 or in Docker with NODE_ENV=production.

Common Pitfalls

  • Blocking the event loop — a sync loop or readFileSync freezes all connections. Keep the loop free.
  • Unhandled promise rejections — crash the process. try/catch every await, or attach .catch().
  • async route handlers that throw — don’t reach Express’s error middleware without wrapping. Use wrap() or express-async-errors.
  • .pipe() without error handling — use stream/pipeline so errors propagate.
  • Dev deps in dependencies — bloats production. Use -D / --save-dev.
  • Not committing the lockfile — non-reproducible installs. Commit package-lock.json.
  • Trusting req.body — always validate with Zod/joi; never build SQL/shell from it.
  • Single process on multi-core — use clustering (PM2 -i max) or one process per container.
  • NODE_ENV unset in production — slower V8, verbose errors. Set NODE_ENV=production.

Further Reading

Node + Express is the backend JS stack — these PyShine tutorials are its prerequisites and companions:


Node’s power is its simplicity: one language front-to-back, one thread, an event loop, and a tiny core you can hold in your head. The five stages here — runtime, modules, async, Express, production — cover everything from a “hello world” server to a clustered, tested, containerized API. The single most important habit is never block the loop: once that’s a reflex, the rest is standard web-backend work. Run every snippet above, write one endpoint, hit it with curl, wrap it in Docker, and you’ve built your first production Node service.

Watch PyShine on YouTube

Contents