Node.js
Node.js is a server runtime for JavaScript that runs all of your code on one thread and hands every wait to the operating system or a small thread pool. That one design is why a single process holds ten thousand idle connections, and why one slow function delays every request at once. The page opens with seventeen things a Node server does and which thread pays for each, then covers the event loop and the pool, releases and support — Node 27 moves to one major a year — what is built in now, scaling and a clean shutdown, five documented cases, and where it degrades.
- Current version
- 26 · LTS from 28 Oct 2026
- Model
- One thread for your code
- Support
- 30 months of LTS; one major a year from 27
Where every kind of work runs — seventeen things a Node server does
A Java or Go server gives a waiting request its own thread, so a slow request mostly slows itself. Node gives your code exactly one thread. Every request in the process takes turns on it — that turn-taking is the event loopone thread taking turnsA loop on a single thread that takes finished work from a queue — a reply that has arrived, a timer that has fired — and runs its callback, one at a time. It lets one thread juggle thousands of waits, and it stops for everyone while any single callback runs.Full article →All terms — and anything that cannot wait without a thread is sent somewhere else. So the useful question about any line of server code is not "is it fast?" but "which thread pays for it, and who waits while it runs?". Here are seventeen things a Node server does, with the answer for each. Later sections cite the rows by number.
| # | What the server is doing | Where it runs | Who waits while it runs |
|---|---|---|---|
| 1 | Your handler’s own code between two awaits | The loop thread | Every other request in the process |
| 2 | JSON.parse() of a 20 MB request body | The loop thread | Every request, for tens of milliseconds |
| 3 | A regular expression run on user input | The loop thread | Every request — for seconds, if the pattern backtracks |
| 4 | Sorting, filtering or totalling a large array | The loop thread | Every request |
| 5 | Rendering HTML on the server — a template, React | The loop thread | Every request |
| 6 | Any …Sync call — readFileSync, pbkdf2Sync, gzipSync | The loop thread | Every request, for the whole disk read or computation |
| 7 | console.log to a file, or to a terminal on Linux | The loop thread — these writes are synchronous | Every request; a write to a pipe is asynchronous |
| 8 | A database query over the network — PostgreSQL, MySQL, Redis | The kernel — no thread waits | Nobody; parsing the answer is row 1 again |
| 9 | Holding ten thousand idle connections or WebSockets | The kernel — no thread waits | Nobody; each costs memory, not a thread |
| 10 | A timer — setTimeout, setInterval | The loop thread, when it fires | Nobody — but it fires late while rows 1–7 run |
| 11 | Every asynchronous fs call — readFile, writeFile, stat | The thread pool — four threads | The other pool rows |
| 12 | dns.lookup() — which http, fetch and most database drivers call for every host name | The thread pool — four threads | The other pool rows; a slow DNS server holds a thread per lookup |
| 13 | crypto.pbkdf2, scrypt, randomBytes, generateKeyPair | The thread pool — four threads | The other pool rows |
| 14 | Asynchronous zlib — gzip and Brotli | The thread pool — four threads | The other pool rows |
| 15 | A native addon’s asynchronous work — hashing with the bcrypt package | The thread pool, usually | The other pool rows |
| 16 | A worker_threads worker | Its own thread, with its own event loop | Nobody on the main loop; data is copied in and out |
| 17 | A child process, or a consumer reading jobs from a queue | Another process | Nobody in this process |
The loop that must never wait, and the pool of four
A traditional server gives each connection a thread, and that thread sits blocked while the database answers. Node never blocks on a connection at all. It asks the operating system to watch every socket at once — epoll on Linux, kqueue on macOS, IOCP on Windows — and to report which ones are ready. So ten thousand idle connections cost ten thousand small objects in memory rather than ten thousand threads. When a socket is ready, libuv, the C library under Node, puts its callback in a queue, and the loop thread runs it.
The loop goes round in phases: due timers first, then the callbacks of finished I/O, then setImmediate callbacks, then closed connections, and round again. Between any two callbacks it drains the queue of resolved promises. The ordering rules for promises and timers belong to the JavaScript page; for a server, one consequence matters. A callback is never interrupted. While one runs, the loop does not go round, so no other request is parsed, no response is sent and no timer fires. That is why the same slow function is a nuisance in Java and an outage in Node.
The measure that names this problem is event-loop delay: how late the loop gets back to work compared with when it should have. perf_hooks.monitorEventLoopDelay(), available since Node 11, records it as a histogram; export its 99th percentile as a metric next to latency. A second number, event-loop utilisation from performance.eventLoopUtilization(), says what share of the time the loop was busy at all. When delay climbs, the cause is synchronous code in your own process — rows 1–7 — and no amount of database tuning will move it. A CPUCentral Processing Unit — the processorThe processor that executes a program’s instructions. Calling a task “CPU-bound” means it is limited by computation rather than by waiting on disk or network — and the two need opposite fixes, so the distinction is worth making early.All terms profile finds the function: node --cpu-prof writes one that Chrome DevTools opens as a flame graph.
The pool of four, and the DNS trap
Some operations have no "tell me when it is ready" interface in the operating system: reading a file, resolving a host name through the system resolver, hashing a password, compressing a response. libuv runs those on a thread pool — rows 11–15 — and the pool has four threads by default. The documentation spells out the consequence: if any of these operations takes a long time, "other (seemingly unrelated) APIApplication Programming Interface — contract between programsThe rules one program follows to call another: which requests it may send, in what shape, and what comes back. To the caller it is a contract — everything behind it stays a black box, which is exactly what lets the other side be rewritten without breaking you.Full article →All terms that run in libuv’s threadpool will experience degraded performance". Four logins hashing at once fill the pool, and a request that only wanted to read a small file waits behind them.
The trap most teams find the hard way is row 12. Resolving a host name with dns.lookup() looks asynchronous from JavaScript, but the documentation says it "is implemented as a synchronous call to getaddrinfo(3) that runs on libuv’s threadpool" — and the http module, fetch and most database drivers call it for every host name they connect to. A DNS server that takes five seconds to answer holds a pool thread for five seconds per lookup. Four such lookups, and every file read in the process queues behind them, although the disk is idle and so is the CPU.
There are three fixes, in order of how often they are right. Reuse connections, so a host name is resolved once rather than on every request — a keep-alive agent does this. Move heavy hashing and compression off the shared pool: a worker thread, a separate service, or a library that brings its own threads. And, if the pool genuinely needs to be bigger, set UV_THREADPOOL_SIZE in the environment before Node starts; the maximum is 1024. Setting it from inside the code "is not guaranteed to work", the documentation warns, because the pool is created before your first line runs.
| What you see | What it usually means | Where to look |
|---|---|---|
| Latency rises for every endpoint at once, one core at 100% | Something holds the loop thread (rows 1–7) | Event-loop delay, then a CPU profile |
| File reads and outgoing calls slow down only while logins spike | The pool is full (rows 11–15) | How much hashing and compression runs at once |
| Outgoing calls are slow, while the remote service reports itself fast | Name resolution on the pool (row 12) | DNS latency; whether connections are reused |
| Memory climbs for hours until the container is killed | A leak — usually a module-level cache, a listener or a timer never removed | Two heap snapshots an hour apart, compared |
| The process exits right after a log line about an unhandled rejection | A promise failed with nobody awaiting it — fatal since Node 15 | The trace in that log line; the missing await or .catch() |
Releases: Current, LTS, and the change that starts this month
Until version 26, Node shipped two major versions a year. Even numbers came out in April, odd numbers in October. Each even line spent six months as "Current", then became an LTSLong-Term Support — a release patched for yearsA release line that keeps receiving security and critical fixes for a published period after newer versions ship, but no new features. It lets a team upgrade on its own schedule — until the end date on the support table, after which the next vulnerability has no fix at all.All terms line in October — long-term support — with 12 months of active fixes and 18 more of maintenance: 30 months in all. Odd lines lived about eight months and never got LTS. The rule for production followed directly: run an even-numbered LTS line, and upgrade before its end-of-life date. The table lists what each recent line added that you will meet in code, and how long it is patched.
| Version | Released | What you will meet in code | Supported until |
|---|---|---|---|
| 18 | Apr 2022 | A global fetch without a flag, an experimental test runner | Ended 30 Apr 2025 |
| 20 | Apr 2023 | A stable test runner, the permission model as an experiment, --env-file | Ended 30 Apr 2026 |
| 22 | Apr 2024 | require() of ES modules, a WebSocket client, stable --watch, node --run, node:sqlite, TypeScript files run directly | 30 Apr 2027 |
| 24 | May 2025 | npm 11, URLPattern as a global, using declarations, stable type stripping | 30 Apr 2028 |
| 26 | May 2026 | Temporal on by default, Undici 8, --experimental-transform-types removed | 30 Apr 2029 |
| 27 | Alpha from 28 Oct 2026; 27.0.0 in Apr 2027 | The first line on the new schedule; keep-alive timeout of 65 s by default | 30 Apr 2030 |
What is built in now: modules, TypeScript, and fewer packages
Node has two module systems, and most projects still meet both. CommonJS — require() and module.exports — is the original one, resolved while the code runs. ES modules — import and export — are the language standard. For years the boundary between them was a wall: a CommonJS file could not require() an ES module, so one dependency that went ESM-only forced its users to migrate. Since Node 22.12 and 20.19 that works without a flag, and it has been stable since 25.4. One exception remains: a module that uses top-level await cannot be loaded this way, and the call fails with ERR_REQUIRE_ASYNC_MODULE. New code should be ES modules, with "type": "module" in package.json.
Node also runs TypeScriptJavaScript with type checkingJavaScript plus a description of what each function takes and returns, checked in the editor before anything runs. It never reaches the browser — it is compiled back into plain JavaScript first, so the types are a development-time tool and nothing more.Full article →All terms files directly now, on by default since 22.18 and stable since 24.12. It is worth knowing exactly what that means, because the name promises more than it does. Node strips the type annotations — replaces them with spaces — and runs what is left. It does no type checking at all, so a type error still runs. It ignores tsconfig.json, so path aliases do not resolve. It handles only syntax that can be erased: an enum or a constructor parameter property needs code generated for it and is rejected. And it refuses TypeScript files inside node_modules, so packages still ship JavaScript. Keep tsc --noEmit in the build; Node replaces the compiler’s output step, not its checker.
| The package you may no longer need | Built in instead | Since |
|---|---|---|
node-fetch, axios for simple calls | A global fetch | 18; stable in 21 |
jest, mocha for a library’s own tests | node:test and node --test | Stable in 20 |
nodemon | node --watch | Stable in 22 |
dotenv | node --env-file=.env | 20.6; stable in 24.10 |
ts-node, tsx for code without enum | Type stripping | 22.18; stable in 24.12 |
ws as a client | A global WebSocket | 22 |
npm run just to start a script | node --run | 22 |
better-sqlite3 | node:sqlite | 22.5; not yet stable |
Install time is where Node’s supply-chain risk actually lives, and it is a different risk from the one the JavaScript page describes. That page is about the dependency tree — what you ship to users. This one is about what runs on your machine. npm install executes each package’s preinstall, install and postinstall scripts with your user’s rights: on a laptop that has SSH keys, on a build server that holds cloud credentials. Three controls close most of it. Install with npm ci from the committed lockfileexact versions of every dependencyA file the package manager writes next to the list of dependencies, recording the exact version of every package installed — including the ones your dependencies pulled in. Committed to the repository, it makes every install identical; without it, two builds of the same commit can run different code.All terms. Run it with --ignore-scripts in the pipeline, and allow scripts only for the few packages that compile native code. Or use pnpm, which since version 10, in January 2025, runs no dependency scripts unless they are listed.
Scaling, crashing, and shutting down cleanly
One Node process uses one core for your code, so a sixteen-core machine running a single process uses 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 |
|---|---|---|
| More processes | N copies behind a load balancer — in practice one process per container, with the orchestrator adding replicas. | Almost always. It is the default answer for a stateless HTTP service. |
worker_threads (row 16) | Real threads inside one process, each with its own event loop and no shared variables. | One heavy operation inside an otherwise waiting service — a PDF, an image, a big parse. |
| A queue (row 17) | 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. |
Whichever you choose, the constraint is the same: processes share nothing, so an in-memory cache, a rate-limit counter or a session in a module-level variable is wrong the moment there is a second process. Those belong in Redis or the database. A worker thread is not a cheap thread either: it starts its own copy of the engine, costs megabytes and tens of milliseconds to start, and receives its data copied. Keep a small pool of workers alive and send them jobs, rather than starting one per request.
A clean shutdown, and the 502 nobody can reproduce
An orchestrator stops a container by sending SIGTERM and, after a grace period — thirty seconds by default in Kubernetes — killing it. A process that ignores the signal loses every request in flight on every deploy. Handle it: call server.close(), which stops accepting connections and, since Node 19, also closes the idle ones; let in-flight requests finish; close the database pool; exit well inside the grace period.
The second failure needs no deploy at all. A load balancer keeps idle connections to your process open, to reuse them for the next request. An AWS Application Load Balancer keeps them for 60 seconds by default. Node closes an idle connection after its keepAliveTimeout — 5 seconds by default, plus a one-second buffer since 24.6 and 22.19. So Node always closes first. Most of the time that is harmless. But when the load balancer sends a request down a connection at the very moment Node closes it, the request meets a closing socket, and the client receives a 502 Bad Gateway. AWS documents exactly this, and recommends that the application’s idle timeout be longer than the load balancer’s.
The symptom is what makes it expensive: a handful of 502s in many thousands of requests, no error in the application log, and nothing on a quiet test environment, because the race needs real traffic to hit the exact moment. The fix is one line — server.keepAliveTimeout = 65_000, longer than the load balancer’s 60 seconds — and Node itself takes the same view from version 27, where 65 seconds becomes the default. Until then, every service behind a load balancer needs the line.
Five cases, and what each one was missing
Each case is documented by the people it happened to — engineering blogs, the project’s own changelogthe record of what changed, per versionA file, usually CHANGELOG.md, that lists the notable changes in each version of a piece of software, newest first, grouped as added, changed, deprecated, removed, fixed and security. It is written for the person deciding whether to upgrade, which is why a dump of commit messages does not count as one.Full article →All terms and release records, the security advisory, the registry’s announcement. Between them they cover the bet Node makes, its governance, the loop, a dependency on the loop, and install time.
| Case | What happened | What was missing |
|---|---|---|
| PayPal, 2013 | The same page built in Java and in Node: twice the requests per second, on one core against five | Nothing — but the result holds for pages that wait, and the post says so |
| io.js, 2014–2015 | Contributors forked Node over governance and a stalled release cadence; the projects merged in Node 4 | Open governance and a predictable schedule |
| Netflix, Nov 2014 | Latency rose 10 ms an hour until reboots, from duplicate route handlers | A CPU profile of the loop |
| path-to-regexp, Sep 2024 | One crafted URL held the loop of any Express 4 app with a /:a-:b route | A pattern that cannot backtrack, and a URL length limit |
| Shai-Hulud, Sep and Nov 2025 | A worm spread through npm install scripts, stealing tokens and publishing itself | Install scripts off by default, short-lived publish tokens |
The first case is the bet itself, measured honestly. In 2013 PayPal rebuilt its account overview page, one of the busiest pages on the site, in Node, while a team kept building the same page in Java on its Spring-based framework as a fallback. Both passed the same functional tests. In November 2013 Jeff Harrell published the numbers: the Node version was built almost twice as fast by fewer people, and served double the requests per second — "using a single core for the node.js application compared to five cores in Java" — with a 35% lower average response time, about 200 ms. The detail that explains it is in the same post: each of the page’s three routes made between two and five API requests and stitched the answers together. That is rows 8 and 9: a page that spends its life waiting. The post ends with its own disclaimer — "your mileage may vary" — and it is the right one. The same rewrite of a page that computes would have shown the opposite.
The third case is row 1 hidden inside a framework. In November 2014 Yunong Xiao described on the Netflix Tech Blog a Node service whose latency started at 1 ms and rose by 10 ms every hour, peaking around 60 ms before the instances were rebooted. CPU rose with it. The team’s own handlers measured a constant 1 ms, and the heap stayed near 1.2 GB, so it was neither their code nor a memory leak in the usual sense. A CPU flame graph showed very tall stacks inside Express’s router. Express kept every route handler in one array and walked it for each request. A job in Netflix’s code refreshed the routes from an external source ten times an hour — and each time it also added the same static-file handler again, in front of the API routes. Each duplicate cost about 1 ms on every request: ten an hour, 10 ms an hour. Nothing failed; the loop just did more work per request every hour. Rolling reboots hid it. A profile found it.
The fifth case is install time. On 14 September 2025 GitHub was notified of Shai-Hulud: a self-replicating worm that entered npm through compromised maintainer accounts and injected post-install scripts into their packages. When a developer or a build server installed an infected version, the script searched the machine for secrets — npm tokens and cloud credentials among them — and used any npm token it found to publish infected versions of that maintainer’s other packages. GitHub removed more than 500 packages. On 22 September it announced the change of model: local publishing with two-factor authentication, granular tokens that live for seven days, trusted publishing from build systems, and the deprecation of classic tokens. A second wave in November 2025 moved to preinstall — which runs even before the package is fully installed — touched about 700 packages and left more than 25,000 GitHub repositories of stolen secrets. Every infection started the same way: an install script running with the rights of whoever typed npm install.
Where it degrades
Node’s model is unforgiving in one direction and generous in the other. Waiting is almost free, so a service that only waits scales further on one process than most runtimes do on twenty threads. Computing is shared by everyone, so one careless line taxes every user at once. Almost every failure is one of the seventeen rows put on the wrong thread:
- Parsing, sorting, rendering or hashing on the loop thread, which slows every concurrent request (rows 1–6).
- A regular expression written by hand and run on user input, with no limit on its length (row 3).
- Password hashing, compression and DNS fighting over four pool threads (rows 11–15).
- No event-loop delay metric, so the one number that explains a slowdown is never on a dashboard.
- State in a module-level variable, which silently diverges the moment there is a second process.
- A global handler that swallows errors and keeps a process running in an unknown state.
- No
SIGTERMhandler, so every deploy cuts off the requests in flight. - The default keep-alive timeout behind a load balancer, which shows up as rare 502s nobody can reproduce.
- Install scripts running on build servers that hold publish tokens and cloud credentials.
- A line past its end-of-life date — today, anything older than 22.
The honest limit is the single thread itself. A service whose requests compute rather than wait — image processing, analytics, heavy transformation — fights the model the whole way. Worker threads and queues move the work out, but then most of the service is written around getting the work away from Node, and a runtime with real threads or a compiled language would have been the simpler choice. The reverse also holds: the reasons teams give for leaving Node are often rows 1–7 that a profile would have found in an afternoon.
The practical check takes five minutes. Run node -v in the production image and find the row in the releases table. Check that event-loop delay is exported and look at its 99th percentile at peak. Compare keepAliveTimeout with the load balancer’s idle timeout. And look at how the pipeline installs dependencies: npm ci, with scripts off, from the lockfile.
When to use it
Use it when
- APIs and gateways whose work is calling other systems and shaping their answers.
- Real-time features — WebSocketa connection kept open both waysA connection that stays open in both directions: unlike HTTP, the server can send a message on its own without waiting to be asked. Chats and live feeds are built on it, and so is most of what looks like the page updating by itself.All terms, 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 on both sides.
- Server-rendered JavaScript applications, where the framework needs a Node runtime anyway.
Avoid it when
- Services whose requests compute rather than wait — encoding, analytics, heavy transformation.
- Anything that needs shared mutable state inside the process; it does not survive a second process.
- Build environments that cannot turn off install scripts or keep publish tokens short-lived.
- Running a line past its end-of-life date — today, anything older than 22.
Sources and method
What this page stands on
- 19 sources
- 3 documented failures
- Checked October 13, 2026
Practices and where the terms come from
which thread pays for each operation, and the release schedule- DocumentationNode.js — Command-line API (UV_THREADPOOL_SIZE) (opens in a new tab)nodejs.org
- DocumentationNode.js — DNS: Implementation considerations (opens in a new tab)nodejs.org
- DocumentationNode.js — Process: A note on process I/O (opens in a new tab)nodejs.org
- DocumentationNode.js — HTTP (opens in a new tab)nodejs.org
- DocumentationNode.js — Modules: TypeScript (opens in a new tab)nodejs.org
- DocumentationNode.js — Modules: CommonJS modules (opens in a new tab)nodejs.org
- DocumentationNode.js — Permissions (opens in a new tab)nodejs.org
- Documentationlibuv — Thread pool work scheduling (opens in a new tab)libuv
- DocumentationEdit attributes for your Application Load Balancer (opens in a new tab)AWS Elastic Load Balancing
- Documentationnodejs/Release — Node.js Release Working Group (opens in a new tab)GitHub
- ArticleEvolving the Node.js Release Schedule (opens in a new tab)Node.js Blog · Mar 10, 2026
The failures this page learns from
Netflix, path-to-regexp and Shai-Hulud, in “Five cases”- PostmortemYunong Xiao — Node.js in Flames (opens in a new tab)Netflix Technology Blog · Nov 19, 2014
- Documentationpath-to-regexp outputs backtracking regular expressions (CVE-2024-45296) (opens in a new tab)GitHub Advisory Database · Sep 9, 2024
- PostmortemXavier René-Corail — Our plan for a more secure npm supply chain (opens in a new tab)The GitHub Blog · Sep 22, 2025
- ArticleSha1-Hulud 2.0 Supply Chain Attack: 25K+ Repos Exposed (opens in a new tab)Wiz · Nov 2025
- Documentationpnpm 10 — release notes (opens in a new tab)GitHub · pnpm · Jan 7, 2025
Researched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.
Found this useful?
Share it with someone who is working on the same problem.