Node.js
Node.js is JavaScript on the server, and the runtime that made "one process, thousands of connections" ordinary. It gets there by never waiting for anything itself: I/O is handed to the operating system or to a small thread pool, and your code runs on exactly one thread in between. That is its strength for API work and its hard limit for anything computational. This page covers the I/O model, scaling, the module split, and the mistakes that only appear under load.
- Your code
- One thread per process
- I/O
- Delegated, not threaded
- Scaling
- More processes
What Node delegates, and to whom
A traditional server gives each connection a thread, and that thread sits blocked while the database answers. Node does the opposite: it never blocks on a connection at all. It asks the operating system to watch every socket at once and tell it which ones are ready, so ten thousand idle connections cost ten thousand small objects rather than ten thousand threads. That is the whole reason a single Node process can hold connection counts that would exhaust a thread-per-request server.
Not everything has a non-blocking system call behind it, though, and this is the part that surprises people. Filesystem reads, DNS lookups, compression and password hashing have no equivalent of socket polling, so libuv runs them on a thread pool — four threads by default. Those threads are real, and they are shared by every one of those operations in the process. Hash four passwords with bcrypt at the same time and the pool is full; a fifth request that only wanted to read a file now waits behind them.
Your JavaScript is delegated nowhere. It runs on the single loop thread, and while it runs, nothing else in that process does — no other request is parsed, no callback fires, no timer runs. A function that spends two hundred milliseconds sorting an array adds two hundred milliseconds to the latency of every request currently in flight, not just its own. This is the fundamental difference between Node and a threaded runtime, and it is why the same slow function is a nuisance in Java and an outage in Node.
Scaling past one core
One Node process uses one core for your code, so a sixteen-core machine running a single process is using one sixteenth of what you paid for. There are three ways out, and they are not interchangeable — each fits a different shape of problem.
| Approach | What it is | Use it when |
|---|---|---|
| Multiple processes | Run N copies behind a load balancer, or use the cluster module to share one port. | Almost always. It is the default answer for a stateless HTTP service. |
| worker_threads | Real threads inside one process, with their own event loop and no shared variables. | One CPU-heavy operation inside an otherwise I/O-bound service. |
| A queue | Push the job to a broker and let a separate consumer process do it. | Work that can finish after the response — reports, emails, media, imports. |
In a container platform the first option usually collapses into "run one process per container and let the orchestrator scale the replicas", which is simpler than the cluster module and gives you restarts and rolling deploys for free. Whichever you choose, the constraint it imposes is the same: processes share nothing, so an in-memory cache, a rate-limit counter or a session held in a local variable is wrong the moment there is a second process. Those belong in Redis.
Modules, dependencies and the standard library
Node has two module systems and you will meet both. CommonJS — require and module.exports — is the older one, resolved at runtime and synchronous. ES modules — import and export — are the language standard, statically analysable, and now fully supported. New code should use ES modules; the friction is that a CommonJS file cannot require an ES module, so a project part-way through the migration hits an awkward boundary. Set "type": "module" in package.json, be consistent within a package, and treat a mixed codebase as a migration to finish rather than a state to live in.
The ecosystem is the largest of any runtime and that cuts both ways. A dependency you add pulls in its own dependencies, and the result is a tree you did not read and cannot review — which is a supply-chain risk, not a theoretical one. Three habits keep it manageable: commit the lockfile so builds are reproducible; run npm audit in CI and treat a critical finding as a build failure; and before adding a package, check when it was last published and how many transitive dependencies it brings. A four-line utility with nine dependencies is a liability, and the standard library has grown enough that the four lines are often already there.
On TypeScript: it is effectively the default for new server-side Node, and for a good reason. The boundaries a backend has — HTTP bodies, database rows, message payloads — are exactly where a wrong shape causes an incident, and types make the shape explicit at every call. They do not check the data itself at runtime, so validate what arrives from outside with a schema library, and let the types describe everything after that point.
How this shows up in real delivery
The failure Node teams meet in production is rarely a crash. It is latency that gets worse for everyone at once, with no single slow endpoint to blame — the signature of something occupying the loop thread. The measurement that names it is event-loop lag: schedule a timer for ten milliseconds, see how late it actually fires, and export the difference as a metric. When that number climbs, the cause is your own synchronous code, and no amount of database tuning will move it.
Two other things belong in every service before it takes traffic. Timeouts on every outbound call, because a request to a service that never answers holds your handler open indefinitely and a client that gave up long ago is still costing you memory. And graceful shutdown wired to SIGTERM, because that is the signal an orchestrator sends before it stops a container — handle it by closing the listener and draining, or every deploy silently kills in-flight requests.
Where it degrades
- CPU work on the loop thread — parsing, hashing, image processing — which slows every concurrent request.
- Synchronous filesystem calls in a request path, which block the loop for the duration of the disk read.
- State kept in a module-level variable, which silently diverges the moment there is a second process.
- A global handler that swallows uncaught exceptions and keeps a process running in an unknown state.
- Outbound calls without timeouts, so one slow dependency exhausts the connections of everything upstream.
- Awaiting independent calls in sequence, which multiplies latency for no reason
Promise.allcannot fix. - Dependencies added without reading the tree they bring, which is how a supply-chain compromise arrives.
When to use it
Use it when
- APIs and gateways whose work is calling other systems and shaping their answers.
- Real-time features — WebSockets, streams, long-lived connections — where holding many idle sockets is cheap.
- Teams already writing the front end in TypeScript, who gain shared types and one toolchain across both sides.
- Server-rendered JavaScript applications, where the framework needs a Node runtime anyway.
Avoid it when
- CPU-bound services — encoding, analytics, heavy transformation — unless the work is moved off the loop entirely.
- Anything needing shared mutable state inside the process, which does not survive the move to more processes.
- Long numeric or scientific workloads, where the ecosystem and the runtime are both the wrong shape.
- Environments with a strict dependency-review process, where npm's transitive tree is a governance problem in itself.
Found this useful?
Share it with someone who is working on the same problem.