DevelopmentAll levels

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 — 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 doingWhere it runsWho waits while it runs
1Your handler’s own code between two awaitsThe loop threadEvery other request in the process
2JSON.parse() of a 20 MB request bodyThe loop threadEvery request, for tens of milliseconds
3A regular expression run on user inputThe loop threadEvery request — for seconds, if the pattern backtracks
4Sorting, filtering or totalling a large arrayThe loop threadEvery request
5Rendering HTML on the server — a template, ReactThe loop threadEvery request
6Any …Sync call — readFileSync, pbkdf2Sync, gzipSyncThe loop threadEvery request, for the whole disk read or computation
7console.log to a file, or to a terminal on LinuxThe loop thread — these writes are synchronousEvery request; a write to a pipe is asynchronous
8A database query over the network — PostgreSQL, MySQL, RedisThe kernel — no thread waitsNobody; parsing the answer is row 1 again
9Holding ten thousand idle connections or WebSocketsThe kernel — no thread waitsNobody; each costs memory, not a thread
10A timer — setTimeout, setIntervalThe loop thread, when it firesNobody — but it fires late while rows 1–7 run
11Every asynchronous fs call — readFile, writeFile, statThe thread pool — four threadsThe other pool rows
12dns.lookup() — which http, fetch and most database drivers call for every host nameThe thread pool — four threadsThe other pool rows; a slow DNS server holds a thread per lookup
13crypto.pbkdf2, scrypt, randomBytes, generateKeyPairThe thread pool — four threadsThe other pool rows
14Asynchronous zlib — gzip and BrotliThe thread pool — four threadsThe other pool rows
15A native addon’s asynchronous work — hashing with the bcrypt packageThe thread pool, usuallyThe other pool rows
16A worker_threads workerIts own thread, with its own event loopNobody on the main loop; data is copied in and out
17A child process, or a consumer reading jobs from a queueAnother processNobody 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.

Node handles thousands of connections on one thread because it never waits on them itself. Network I/O is handed to the operating system, which watches every socket at once and reports readiness, so a connection costs memory rather than a thread. Work with no asynchronous system call behind it — filesystem, DNS, compression, password hashing — goes to a small pool that defaults to four threads. Your own JavaScript is delegated nowhere: it runs on the single loop thread, so CPU-bound code stalls every connection in the process at once.

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

Node runs file system calls, dns.lookup, the asynchronous crypto functions and zlib on one shared pool of four threads by default. When two slow DNS lookups and two password hashes hold all four, a file read that needs a millisecond of work waits behind them for seconds — although the loop thread is free, the disk is idle and so is the CPU. The pool can be enlarged with UV_THREADPOOL_SIZE, set in the environment before Node starts, up to 1024 threads.

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 seeWhat it usually meansWhere 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 spikeThe pool is full (rows 11–15)How much hashing and compression runs at once
Outgoing calls are slow, while the remote service reports itself fastName resolution on the pool (row 12)DNS latency; whether connections are reused
Memory climbs for hours until the container is killedA leak — usually a module-level cache, a listener or a timer never removedTwo heap snapshots an hour apart, compared
The process exits right after a log line about an unhandled rejectionA promise failed with nobody awaiting it — fatal since Node 15The 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 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.

VersionReleasedWhat you will meet in codeSupported until
18Apr 2022A global fetch without a flag, an experimental test runnerEnded 30 Apr 2025
20Apr 2023A stable test runner, the permission model as an experiment, --env-fileEnded 30 Apr 2026
22Apr 2024require() of ES modules, a WebSocket client, stable --watch, node --run, node:sqlite, TypeScript files run directly30 Apr 2027
24May 2025npm 11, URLPattern as a global, using declarations, stable type stripping30 Apr 2028
26May 2026Temporal on by default, Undici 8, --experimental-transform-types removed30 Apr 2029
27Alpha from 28 Oct 2026; 27.0.0 in Apr 2027The first line on the new schedule; keep-alive timeout of 65 s by default30 Apr 2030
Until version 26, Node shipped an even-numbered line every April and an odd-numbered one every October; only even lines became LTS, with 12 months of active support and 18 of maintenance. Version 25, the last odd line, ended in June 2026 without ever reaching LTS. From 27 there is one major line a year: an Alpha from October, Current from April, then 30 months of LTS — and every line becomes LTS. Node 26, which goes LTS on 28 October 2026, is the last line on the old model.

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 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 needBuilt in insteadSince
node-fetch, axios for simple callsA global fetch18; stable in 21
jest, mocha for a library’s own testsnode:test and node --testStable in 20
nodemonnode --watchStable in 22
dotenvnode --env-file=.env20.6; stable in 24.10
ts-node, tsx for code without enumType stripping22.18; stable in 24.12
ws as a clientA global WebSocket22
npm run just to start a scriptnode --run22
better-sqlite3node:sqlite22.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 . 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.

ApproachWhat it isUse it when
More processesN 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.

A load balancer keeps idle connections to its targets open so it can reuse them; 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 it always closes first. When the load balancer sends a request down a connection at the moment Node closes it, the client receives a 502 Bad Gateway: rarely, and never on a quiet test environment. A keepAliveTimeout longer than the load balancer’s idle timeout, such as 65 seconds, makes the load balancer close first; it is the default from Node 27.

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

CaseWhat happenedWhat was missing
PayPal, 2013The same page built in Java and in Node: twice the requests per second, on one core against fiveNothing — but the result holds for pages that wait, and the post says so
io.js, 2014–2015Contributors forked Node over governance and a stalled release cadence; the projects merged in Node 4Open governance and a predictable schedule
Netflix, Nov 2014Latency rose 10 ms an hour until reboots, from duplicate route handlersA CPU profile of the loop
path-to-regexp, Sep 2024One crafted URL held the loop of any Express 4 app with a /:a-:b routeA pattern that cannot backtrack, and a URL length limit
Shai-Hulud, Sep and Nov 2025A worm spread through npm install scripts, stealing tokens and publishing itselfInstall 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 SIGTERM handler, 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 — , 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

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.