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
requireorimportinto Node - Build tools (Vite, Webpack, esbuild, Turbopack)
- Many CLIs (the
ghCLI 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:
-
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.
-
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.
-
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, DOM | process, Buffer, fs, path, os |
fetch, WebSocket, XMLHttpRequest | fetch (since Node 18), http, https, net |
setTimeout, setInterval | Same + 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 exactlyTwo key files:
| File | What’s in it |
|---|---|
package.json | Your project’s manifest. Lists dependencies (with version ranges like ^1.0.0), scripts, project metadata. |
package-lock.json | The 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.
| System | Syntax | File extension |
|---|---|---|
| CommonJS (CJS) — old | const fs = require("fs") | .js (default) or .cjs |
| ES Modules (ESM) — modern, web-standard | import { 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 dynamicimport(). - An ESM file can
importfrom 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 enginesfield 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:
- Callbacks (old) —
fs.readFile("a.txt", (err, data) => { ... }) - Promises —
fs.promises.readFile("a.txt").then(data => ...) - 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:
| Runtime | Differences from Node |
|---|---|
| Bun | New 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. |
| Deno | Created 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 Edge | V8 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 --initThis 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
tscand 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.logis synchronous and blocks. In hot code paths, structured logging viapinoor 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
--inspector APM tools (Sentry, Datadog). -
Unhandled promise rejections crash modern Node. A
then()withoutcatch(), anawaitwithout try/catch — if it rejects, Node terminates the process by default. Always handle errors. -
__dirnameand__filenamedon’t exist in ESM. Useimport.meta.url:import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); -
package.jsonexports can hide files. Modern packages declare"exports"paths; trying to import an “internal” file fails even if it exists on disk. -
node_modulesis huge. A medium project can have 800MB of node_modules. Always.gitignoreit. 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
enginesin package.json. -
Native modules (binaries) need to be recompiled per platform. A Mac install of
sharpornode-canvaswon’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.readFileSyncin handler code blocks everyone. Read files at startup (in module scope) or asynchronously. -
Buffer vs string encoding.
Buffer.from("café").lengthis 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; useprocess.exitCode = 1instead and return. -
Default
fetchtimeout is unlimited. A hanging external service can keep your function alive until the platform kills it. Always setAbortSignal.timeout(ms). -
JSON.parsethrows 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. -
Dateis not timezone-aware. Use a library (date-fns,luxon, or the newTemporalproposal) 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 justvitest). -
Lockfiles drift between npm versions. A team using mixed npm versions can produce different lockfiles. Pin npm version with
voltaor apackage.jsonpackageManagerfield. -
The package supply chain is a real attack surface. Malicious npm packages happen (
event-stream,ua-parser-js,colors). Pin versions, auditnpm 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
- What is a backend? đźź©
- APIs — the big picture 🟩
- REST APIs đźź©
- Serverless functions 🟩 — Node functions on Vercel/Netlify
- Edge functions 🟥 — V8 without full Node
- JavaScript 🟩 — the language Node runs
- TypeScript 🟩 — what almost all Node code is now written in
- Next.js 🟩 🟦 — the Node-based framework George uses
- npm 🟥 — the package manager
- The terminal 🟩 — where
nodeandnpmget invoked - Cloudflare 🟩 🟦 — Workers run V8 without Node
- Glossary: Node.js, npm, V8
Sources
- Node.js docs — canonical reference
- Node.js learn portal — concepts and patterns
- Node release schedule — what LTS means and when
- The Node Event Loop — official deep-dive
- npm docs
- Bun docs — the alternative runtime
- Deno docs — the other alternative runtime