Node.js

Status: 🟩 COMPLETE (🟦 LIVING — Node ships a new major version each year) Last updated: 2026-06-19 Plain-English tagline: A program that lets JavaScript run OUTSIDE the browser — most importantly, on servers — making it possible to write your frontend, backend, scripts, and tools all in the same language.


In plain English

For most of the web’s history, JavaScript only ran inside a browser. It was the “scripting language for web pages” and nothing more. If you wanted to write a server, you used Python, Ruby, Java, PHP, or C#. JavaScript was a junior language locked in a sandbox.

In 2009, Ryan Dahl took the V8 JavaScript engine from Google Chrome — the part that actually runs JS — and ripped it out of the browser. He bolted on filesystem access, network access, and a small standard library, and called it Node.js. Suddenly JavaScript could run on a server, read files, make HTTP requests, and do everything a “real” backend language could do.

This was transformative because it meant developers could now write their entire stack in one language:

  • Frontend code (the browser JS that always existed)
  • Backend code (now Node.js)
  • Build tools, scripts, tests (also Node.js)
  • Mobile apps (React Native — also JS)
  • Desktop apps (Electron — also JS, on top of Node)

Today in 2026, Node powers most of the modern web stack you’ll touch:

  • Next.js (a Node-based framework)
  • Vercel functions (Node runtime)
  • npm (the package manager — itself a Node program)
  • The Anthropic SDK, Supabase SDK, Stripe SDK, etc. — all JavaScript libraries you require or import into Node
  • Build tools (Vite, Webpack, esbuild, Turbopack)
  • Many CLIs (the gh CLI is in Go, but Vercel’s CLI, Wrangler, the AWS CDK are Node)

Even Claude Code is a Node program at its core.

Node is so dominant in JavaScript-land that “the backend” for most webapps in this stack is implicitly “Node code running on a server.”


Why it matters

Three reasons Node matters for the work George does:

  1. It unifies the stack. One language, one type system, one package ecosystem from browser button to database query. Switching languages between layers (JS frontend + Python backend) is workable but adds friction.

  2. The library ecosystem is the largest in any language. npm has roughly 3 million packages — more than PyPI, Maven Central, or any rival. Whatever you want to do, there’s a Node library for it.

  3. It’s what modern frameworks assume. Next.js, Astro, SvelteKit, Remix, Nuxt — all Node-based. The entire modern web dev experience presumes you have Node installed.

The trade-off: Node has weaknesses (single-threaded, awkward at CPU-heavy work, package ecosystem prone to abandonment). For most webapp work, none of these matter. For numerical / scientific / ML work, Python is still the right choice.


How Node actually works (the event loop)

Node’s most distinctive technical characteristic is its single-threaded event loop with non-blocking I/O.

Translation: imagine a chef. A blocking chef does one task end-to-end: chop onion, then start frying it, then wait for the pan to heat, then add ingredients, then wait for it to cook. While waiting, they do nothing.

A non-blocking chef puts the oil in to heat, then chops onions while it heats, then starts the frying when the oil is ready, then chops more vegetables while the dish cooks. They never wait idle. They juggle multiple tasks by always working on whatever’s ready.

That’s Node. When you do something I/O-bound (read a file, make a network request, query a database), Node doesn’t block waiting for the answer. It registers a callback (“when this is done, run this code”) and moves on to other work. When the answer arrives, the callback fires.

This makes Node excellent at:

  • Web servers — most of their time is spent waiting for the database or upstream APIs, not computing
  • Realtime servers — websockets, chat, presence
  • APIs that fan out to other services — Node can wait for many things in parallel without spinning up threads

And bad at:

  • CPU-bound computation — single thread; one heavy computation blocks everything else
  • Long-running synchronous code — same reason

For typical webapp backends, Node’s strengths align with what you need.


The Node runtime environment

A Node process has a different set of built-in tools than a browser:

Browser JS has…Node has…
window, document, localStorage, DOMprocess, Buffer, fs, path, os
fetch, WebSocket, XMLHttpRequestfetch (since Node 18), http, https, net
setTimeout, setIntervalSame + setImmediate, process.nextTick
Image, Audio, Canvas(none — no GUI)

Most modern code uses Web standards (fetch, Request, Response, crypto.subtle) so it can run in both Node and browsers (and edge runtimes). The Node-specific APIs (fs, child_process, os) are server-only.

// Reading a file — Node-specific
import { readFile } from "node:fs/promises";
const text = await readFile("./data.txt", "utf8");
 
// HTTP request — works the same in Node and the browser
const res = await fetch("https://api.example.com/data");
const data = await res.json();

The “node:” prefix on imports (node:fs, node:path, node:crypto) is the modern style. It’s explicit about which modules come from Node’s built-ins vs from npm packages.


npm — the package manager

You almost never write Node code without external packages. npm (Node Package Manager) ships with Node and is the default way to install them.

The basics:

npm init -y                        # create a new project (package.json)
npm install express                # add a package (also: npm i express)
npm install -D typescript          # add a dev-only package
npm uninstall express              # remove a package
npm run dev                        # run a script defined in package.json
npm ci                             # clean install matching package-lock.json exactly

Two key files:

FileWhat’s in it
package.jsonYour project’s manifest. Lists dependencies (with version ranges like ^1.0.0), scripts, project metadata.
package-lock.jsonThe exact resolved versions of every dependency (and their dependencies, recursively). Commit this. It’s what makes npm ci reproducible.

There are alternatives: pnpm (faster, deduplicates disk usage), yarn (Facebook’s, originally faster than npm but less so now), bun (a different JS runtime that includes a fast package manager). For most projects, npm is fine and is the safest default.


CommonJS vs ESM — the two module systems

A persistent source of confusion: Node has TWO incompatible module systems.

SystemSyntaxFile extension
CommonJS (CJS) — oldconst fs = require("fs").js (default) or .cjs
ES Modules (ESM) — modern, web-standardimport { readFile } from "fs".mjs, or .js if package.json says "type": "module"

ESM is the future. Every new framework is ESM. Browsers only support ESM. Many older Node libraries are CJS. They mostly interop, but with sharp edges:

  • A CJS file can require() an ESM file only via dynamic import().
  • An ESM file can import from a CJS file but only the default export, or named exports if Node can statically analyze them.
  • Some packages publish both (“dual packages”); some only one.

For a new project: set "type": "module" in package.json and write ESM everywhere. Use TypeScript (which compiles to whichever target you configure). For touching old code, you’ll occasionally hit require() and need to know what it does.


A concrete example: a minimal HTTP server in Node

The “hello world” of Node:

import { createServer } from "node:http";
 
const server = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ message: "hello", url: req.url }));
});
 
server.listen(3000, () => {
  console.log("listening on http://localhost:3000");
});

Save as server.mjs, run node server.mjs. Visit http://localhost:3000 — it responds.

Nobody writes raw http in production. The real-world choices:

  • Express — the oldest, simplest, most familiar web framework
  • Fastify — like Express but faster, more modern
  • Hono — small, fast, works in Node + edge runtimes
  • Next.js — full framework wrapping all of this
  • NestJS — opinionated, Angular-style, big in enterprise

For the kinds of projects George builds, Next.js handles the server layer; you rarely interact with raw HTTP code.


Versions and LTS

Node releases a new major version every April + October. Even-numbered versions (18, 20, 22, 24, 26 in 2026) become LTS (Long-Term Support), maintained for ~30 months. Odd numbers (19, 21, 23) are short-lived.

Always use an LTS version in production. For 2026, Node 22 or Node 24 is the sweet spot. Old projects on Node 18 should plan to upgrade — security patches will end.

To manage versions:

  • nvm (Linux/macOS) or nvm-windows — switch Node versions per project
  • fnm — faster alternative to nvm
  • volta — opinionated; pins versions in package.json
  • engines field in package.json — declares required versions; hosting providers respect it
{
  "engines": {
    "node": ">=22"
  }
}

Vercel reads this and uses a matching Node version for builds and runtime.


The async ecosystem

Modern Node code is asynchronous by default. The three layers:

  1. Callbacks (old) — fs.readFile("a.txt", (err, data) => { ... })
  2. Promises — fs.promises.readFile("a.txt").then(data => ...)
  3. async/await (the current standard) — const data = await fs.promises.readFile("a.txt")

Use async/await. It reads like sync code, handles errors with try/catch, and composes well with the rest of modern JS. Sequence two awaits when one depends on the other; use Promise.all to parallelize independent operations:

// Parallel — both fetches happen at once
const [user, posts] = await Promise.all([
  fetch(`/api/users/${id}`).then(r => r.json()),
  fetch(`/api/users/${id}/posts`).then(r => r.json()),
]);
 
// Sequential — second waits for first
const user = await fetch(`/api/users/${id}`).then(r => r.json());
const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json());

Use the right pattern for the right semantics.


Node vs alternatives (Bun, Deno, edge runtimes)

In 2026, Node has serious competition:

RuntimeDifferences from Node
BunNew JS runtime focused on speed. Faster package install, bundling, server. Drop-in compatible for most code. Used by some teams in production but Node is still safer default.
DenoCreated by Ryan Dahl (same person who made Node) as a “do-over.” Built-in TypeScript, secure-by-default permissions, web-standards-first. Less popular than Bun in 2026 but growing.
Cloudflare Workers / Vercel EdgeV8 isolates without Node. Run a subset of Node APIs (via shims). Fast cold starts. Limited memory. See Cloudflare and Regions & edge.

For day-to-day work on Next.js + Vercel + Supabase, Node remains the right default. It’s the most stable, has the broadest library compatibility, and works identically locally and in production. Bun and Deno are worth tracking; not yet worth switching to mid-project.


TypeScript and Node

Almost every modern Node project uses TypeScript. The setup:

npm install -D typescript @types/node
npx tsc --init

This creates tsconfig.json. Configure to target modern Node:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  }
}

Then you write .ts files and either:

  • Compile to JS with tsc and run the JS
  • Use tsx (npm i -D tsx) to run TS directly: tsx server.ts
  • Use a framework (Next.js, NestJS) that handles TS automatically

Node 22.6+ has native TypeScript execution behind a flag (--experimental-strip-types). Node 24 makes this stable. The era of needing a separate compile step for development is ending.


Common gotchas

  • require() is synchronous and blocks the event loop. Importing huge packages at startup can delay your server’s first response.

  • The single thread blocks on CPU work. If you do heavy computation in your request handler, every other request waits. Move it to a worker thread (node:worker_threads) or offload to a background job system.

  • console.log is synchronous and blocks. In hot code paths, structured logging via pino or similar is dramatically faster.

  • Memory leaks compound silently. A small leak per request is invisible until your container OOMs at 3am. Watch heap growth with --inspect or APM tools (Sentry, Datadog).

  • Unhandled promise rejections crash modern Node. A then() without catch(), an await without try/catch — if it rejects, Node terminates the process by default. Always handle errors.

  • __dirname and __filename don’t exist in ESM. Use import.meta.url:

    import { fileURLToPath } from "node:url";
    const __filename = fileURLToPath(import.meta.url);
  • package.json exports can hide files. Modern packages declare "exports" paths; trying to import an “internal” file fails even if it exists on disk.

  • node_modules is huge. A medium project can have 800MB of node_modules. Always .gitignore it. Modern alternatives (pnpm, Bun) deduplicate to save space.

  • Version mismatch between local and deployment. Locally you run Node 22; production is on 20. Some syntax/APIs differ. Pin with engines in package.json.

  • Native modules (binaries) need to be recompiled per platform. A Mac install of sharp or node-canvas won’t work on Linux. Hosting providers (Vercel) handle this in the build pipeline.

  • Globals are shared across all requests in a serverless function. A module-level cache will leak across users in the same warm instance. Don’t store per-user data in globals.

  • fs.readFileSync in handler code blocks everyone. Read files at startup (in module scope) or asynchronously.

  • Buffer vs string encoding. Buffer.from("cafĂ©").length is not "cafĂ©".length. UTF-8 bytes vs character count.

  • process.exit() is rarely the right answer. It kills outstanding requests, pending writes, etc. Let the process drain naturally; use process.exitCode = 1 instead and return.

  • Default fetch timeout is unlimited. A hanging external service can keep your function alive until the platform kills it. Always set AbortSignal.timeout(ms).

  • JSON.parse throws on bad input. Always wrap in try/catch or use a schema validator (Zod, Valibot).

  • CommonJS / ESM interop is full of edge cases. When a package “doesn’t import correctly,” it’s usually a CJS/ESM mismatch. Try import pkg from "x"; const { foo } = pkg; or vice versa.

  • Date is not timezone-aware. Use a library (date-fns, luxon, or the new Temporal proposal) for any non-trivial date math.

  • npm scripts run in a shell, but it’s not your interactive shell. Aliases and shell functions don’t exist. Always use full paths to binaries (e.g. npx vitest, not just vitest).

  • Lockfiles drift between npm versions. A team using mixed npm versions can produce different lockfiles. Pin npm version with volta or a package.json packageManager field.

  • The package supply chain is a real attack surface. Malicious npm packages happen (event-stream, ua-parser-js, colors). Pin versions, audit npm audit, use Dependabot, avoid pulling random packages for trivial functions.


When to use Node — and when to reach for something else

Use Node when:

  • Building a webapp with a JavaScript frontend (almost always)
  • Writing scripts to automate things in a TypeScript codebase
  • Glueing together multiple HTTP services
  • Realtime / chat / websocket servers
  • Anything where the dev experience matters more than raw performance

Consider alternatives when:

  • Heavy numerical / ML / scientific work → Python (or call Python from Node)
  • Maximum performance at scale → Go, Rust
  • Tight integration with cloud SDKs → Python (best AWS SDK), Java (Spring), C# (.NET)
  • Legacy enterprise integration → whatever the rest of the stack uses

For George’s stack, Node is right ~always.


See also


Sources