Step 01 of 12
How Node.js works
Understand what Node.js is, how one thread serves thousands of requests with the event loop, modules and npm, and what Node is good and bad at.
Node.js lets you run JavaScript outside the browser, and it powers a large share of the world's web backends, from small APIs to Netflix and LinkedIn. If you've done the frontend roadmap, you already know the language. What's new on the server is the runtime: how one thread serves thousands of concurrent requests, how modules and packages work, and which kinds of work Node handles well or badly. This lesson covers all of that, so the rest of the roadmap can focus on building.
What Node.js is
Node.js is a runtime: Google's V8 JavaScript engine (the same one in Chrome), plus a C library called libuv that handles files, networking and timers, plus a standard library of modules such as node:fs, node:http and node:crypto.
What's the same as in the browser: the language, promises, async/await, fetch, setTimeout, URL, structuredClone, the event loop. What's different:
- No
windowordocument: there's no page. Instead there'sprocess(environment variables, arguments, exit codes) and modules for files, networks and processes. - Access to the machine: Node can read any file the user can, open ports and start processes. Server code is trusted code.
- Node versions: even-numbered releases become LTS (long-term support). Use an LTS version (Node 22 or 24) in production.
node --version
node -e "console.log(process.platform, process.version, process.env.HOME)"v22.23.4
linux v22.23.4 /home/anaOne thread, many requests: the event loop
A traditional server might use one thread per request: while a request waits for the database, its thread sits idle, and 10,000 concurrent requests need 10,000 threads. Node takes a different approach: your JavaScript runs on a single thread, and nothing in it ever waits.
When code starts an I/O operation, such as a database query, Node hands it to the operating system (or libuv's small thread pool for file system work), registers a callback, and immediately moves on to other work. When the result arrives, the callback is queued, and the event loop runs it as soon as the thread is free.
This is why Node handles many concurrent connections with little memory: thousands of requests can be waiting on databases and APIs at once, while the single thread only spends time on the short bursts of actual JavaScript between waits.
async/await is how you write this style readably:
import { readFile } from 'node:fs/promises'
async function loadConfig() {
const text = await readFile('config.json', 'utf8') // the thread does other work meanwhile
return JSON.parse(text)
}Never block the event loop
The flip side: since all JavaScript runs on one thread, any slow synchronous code stops every request. While a handler spends 2 seconds in a loop, no other request is served, no timer fires, and health checks time out.
Common ways to block the loop by accident:
- Synchronous APIs:
fs.readFileSync,crypto.pbkdf2Sync,zlib.gzipSync,child_process.execSync. They're fine in startup code and scripts, never in request handlers. - CPU-heavy work in JavaScript: parsing a 200 MB JSON file, resizing an image, a badly written regular expression on user input, sorting a million items.
- Large
JSON.stringify/JSON.parsecalls on big payloads.
// Blocks every other request for as long as this loop runs:
app.get('/primes', (c) => {
const primes = []
for (let n = 2; primes.length < 200_000; n++) if (isPrime(n)) primes.push(n)
return c.json(primes.length)
})For genuinely CPU-heavy work, move it off the main thread with node:worker_threads, into a separate service, or into a background job. You can measure loop health with perf_hooks.monitorEventLoopDelay(): delays above a few tens of milliseconds mean something is blocking.
Modules: ESM and CommonJS
Node has two module systems, and you'll meet both.
ES modules (ESM) are the JavaScript standard, the same import/export as in the browser. Use them for all new code:
// math.ts
export function add(a: number, b: number) {
return a + b
}
// main.ts
import { add } from './math.ts'
import { readFile } from 'node:fs/promises'CommonJS (CJS) is Node's original system, still used by many packages and older code:
const { add } = require('./math')
module.exports = { add }Node decides how to treat a .js file from the nearest package.json: "type": "module" means ESM. Files ending in .mjs are always ESM and .cjs always CommonJS. ESM code can import CommonJS packages, and recent Node versions can also require() ES modules, so mixing mostly works, but pick ESM for your own code.
Prefix built-in modules with node: (node:fs, node:path, node:crypto). It makes clear the module is built in and can't be shadowed by an npm package with the same name.
npm and package.json
Every Node project has a package.json describing it and its dependencies:
{
"name": "orders-api",
"version": "1.0.0",
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"dev": "node --watch src/server.ts",
"start": "node dist/server.js",
"test": "vitest run"
},
"dependencies": {
"hono": "^4.13.0"
},
"devDependencies": {
"vitest": "^5.0.0"
}
}dependenciesare needed at runtime;devDependenciesonly for development and builds.^4.10.0allows any 4.x version from 4.10.0 up (semantic versioning: no major-version jumps).scriptsare commands run withnpm run dev(andnpm test,npm startwithoutrun).package-lock.json(orpnpm-lock.yaml) records the exact version of every installed package. Commit it, and usenpm ciin CI and Docker builds, which installs exactly what the lock file says.
Packages are installed into node_modules/, which can hold hundreds of packages for a modest project. Each dependency is code that runs with your server's full permissions, so add them deliberately, prefer well-maintained ones, and run npm audit (or your scanner of choice) in CI.
The process object
process connects your program to its environment:
const port = Number(process.env.PORT ?? 3000) // configuration from environment variables
console.log(process.argv.slice(2)) // command-line arguments
console.error('something went wrong') // stderr, for logs and errors
process.on('SIGTERM', () => {
// the platform is asking you to shut down: finish in-flight work, then exit
process.exit(0)
})Node 20.6+ can load a .env file without any package: node --env-file=.env src/server.ts. In production, set real environment variables instead.
Unhandled errors crash the process. An exception thrown in a request handler is usually caught by your web framework, but a promise rejection that nothing awaits, or an error thrown in a timer callback, ends up as an unhandledRejection or uncaughtException. Node's default is to print it and exit, and that's the right default: after an unexpected error the process may be in an unknown state, so log it, exit, and let your process manager or container platform start a fresh one.
When Node is a good fit
| Good fit | Weaker fit |
|---|---|
| JSON APIs and backends-for-frontends | CPU-heavy computation (video encoding, machine learning, number crunching) |
| Real-time features: WebSockets, chat, streaming | Code that needs many cores for one task |
| Teams sharing TypeScript and types between frontend and backend | Workloads where predictable low memory and latency matter most (Go and Rust shine there) |
| Gluing many APIs and services together |
The biggest practical advantage is one language across the stack: shared validation schemas and types between the browser and the server, one toolchain and one hiring pool. The Full-Stack roadmap builds on exactly that.
Practice
- Write
loop.tsthat starts a timer printing "tick" every 100 ms, then after 500 ms runs a synchronous loop that takes about 2 seconds (for example, summing numbers up to 2 billion). Observe the ticks stop during the loop. - Replace the synchronous loop with the same work in a worker thread (
new Worker(new URL('./worker.ts', import.meta.url))), and observe the ticks continue. - Print
process.argv,process.env.PORTandprocess.pid, run it withPORT=4000 node loop.ts a b, and stop it cleanly on Ctrl+C (SIGINT).
Node 22.18 and later run .ts files directly by stripping the types, so node loop.ts works without any build step. The next lesson covers TypeScript on Node properly.
Show solution
// loop.ts
import { Worker } from 'node:worker_threads'
console.log({ args: process.argv.slice(2), port: process.env.PORT, pid: process.pid })
const ticker = setInterval(() => console.log('tick', new Date().toISOString().slice(17, 23)), 100)
setTimeout(() => {
const useWorker = process.argv.includes('--worker')
console.log(useWorker ? 'starting work in a worker' : 'starting blocking work')
if (useWorker) {
const worker = new Worker(new URL('./worker.ts', import.meta.url))
worker.on('message', (sum) => console.log('worker finished:', sum))
} else {
let sum = 0
for (let i = 0; i < 2_000_000_000; i++) sum += i
console.log('blocking work finished:', sum)
}
}, 500)
process.on('SIGINT', () => {
clearInterval(ticker)
console.log('\nshutting down')
process.exit(0)
})// worker.ts
import { parentPort } from 'node:worker_threads'
let sum = 0
for (let i = 0; i < 2_000_000_000; i++) sum += i
parentPort?.postMessage(sum)PORT=4000 node loop.ts a b # ticks stop for ~2 s during the loop
PORT=4000 node loop.ts a b --worker # ticks keep coming while the worker computesWithout the worker, the output shows a gap of about two seconds with no ticks: the timer callbacks were ready, but the only thread was busy. With the worker, the computation runs on another thread with its own event loop, and the main thread stays free. The same applies to a server: a blocking handler delays every other request by its full duration.
Key takeaways
- Node.js is V8 plus libuv plus a standard library: browser JavaScript with access to files, networks and processes.
- Your JavaScript runs on one thread; I/O is handed off and its callbacks queued, so one process can serve thousands of waiting requests.
- Never block the event loop with synchronous APIs or heavy computation in request handlers; use workers, jobs or other services.
- Write ES modules with
node:-prefixed built-ins, and commit the lock file and install withnpm ci. - Configure through
process.env, handleSIGTERM, and let unexpected errors crash the process so a fresh one replaces it.
Check yourself
Quiz
0/4 correct · 4 left
1.How does a single Node.js thread serve many concurrent requests?
2.A request handler calls fs.readFileSync on a 500 MB file. What's the effect on other requests?
3.Which install command should CI and Docker builds use?
4.Why is crashing on an uncaught exception a reasonable default for a Node server?
Finished reading?
Mark it complete to fill in your roadmap.