# 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.

Backend with Node.js roadmap · Foundations · 16 min read · https://levelcode.org/roadmaps/node-backend/node-runtime

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 `window` or `document`: there's no page. Instead there's `process` (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.

```bash
node --version
node -e "console.log(process.platform, process.version, process.env.HOME)"
```

```text
v22.23.4
linux v22.23.4 /home/ana
```

## One 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.

```sequence
title: Two requests on one thread
participants: Request A, Event loop, Database, Request B
Request A -> Event loop: GET /orders/1
Event loop -> Database: Query for order 1 (non-blocking)
> The thread doesn't wait. It's free immediately.
Request B -> Event loop: GET /health
Event loop --> Request B: 200 OK
> B is answered while A's query is still running.
Database --> Event loop: Order 1 row
Event loop --> Request A: 200 { order }
```

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:

```ts
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.parse`** calls on big payloads.

```ts
// 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:

```ts
// 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:

```js
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:

```json
{
  "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"
  }
}
```

- `dependencies` are needed at runtime; `devDependencies` only for development and builds.
- `^4.10.0` allows any 4.x version from 4.10.0 up (semantic versioning: no major-version jumps).
- `scripts` are commands run with `npm run dev` (and `npm test`, `npm start` without `run`).
- `package-lock.json` (or `pnpm-lock.yaml`) records the exact version of every installed package. Commit it, and use `npm ci` in 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:

```ts
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](https://levelcode.org/roadmaps/full-stack) builds on exactly that.

## Practice

1. Write `loop.ts` that 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.
2. 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.
3. Print `process.argv`, `process.env.PORT` and `process.pid`, run it with `PORT=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.

**Solution**

```ts
// 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)
})
```

```ts
// 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)
```

```bash
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 computes
```

Without 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 with `npm ci`.
- Configure through `process.env`, handle `SIGTERM`, and let unexpected errors crash the process so a fresh one replaces it.

## Quiz

1. How does a single Node.js thread serve many concurrent requests?

   A. It creates a new thread for each request
   B. I/O is handed to the OS or libuv, and the thread runs other JavaScript until the results arrive
   C. It processes requests strictly one after another, start to finish
   D. V8 runs JavaScript on every CPU core in parallel

   **Answer: B.** The event loop never waits on I/O. While queries and network calls are in flight, the thread handles other requests and runs callbacks as results come back.

2. A request handler calls fs.readFileSync on a 500 MB file. What's the effect on other requests?

   A. None; each request has its own thread
   B. Only requests for the same file are delayed
   C. Every request waits until the read finishes, because the only JavaScript thread is busy
   D. Node automatically moves the read to a worker

   **Answer: C.** Synchronous APIs block the event loop. Use fs/promises in handlers so the thread stays free while the file is read.

3. Which install command should CI and Docker builds use?

   A. npm ci, which installs exactly what the lock file records
   B. npm install, to get the newest compatible versions
   C. npm update
   D. npm audit fix

   **Answer: A.** npm ci reproduces the lock file exactly and fails if it doesn't match package.json, so every build gets identical dependencies.

4. Why is crashing on an uncaught exception a reasonable default for a Node server?

   A. It's faster than logging
   B. Node can't log exceptions
   C. Crashing frees memory faster
   D. After an unexpected error the process may be in an unknown state; a supervisor can start a clean one

   **Answer: D.** Continuing after an unknown failure risks corrupted state and subtle bugs. Log the error, exit, and rely on the container platform or process manager to restart the service.
