Node.js is JavaScript running outside a browser, but the interesting part isn't the language - it's the runtime underneath it. Node pairs Google's V8 engine with a C library called libuv to give JavaScript, a language with no native concept of threads or async I/O, a way to handle thousands of concurrent connections using a single thread of execution.
That single fact - one JS thread, non-blocking I/O everywhere else - explains almost every "why is my server doing that" question a Node developer eventually asks: why one slow request stalls unrelated ones, why worker_threads exist at all, and why horizontal scaling looks different here than on a thread-per-request server. Understanding this model is the highest-leverage thing you can learn before writing production Node code.
Node executes your JavaScript on a single thread and delegates I/O to the OS and a small thread pool, coordinated by an event loop that resumes your code when work completes.
Insight: It explains Node's biggest strength (huge I/O concurrency on modest hardware) and its sharpest edge (one synchronous CPU-bound function can freeze every other request).
Key Concepts:V8, libuv, the event loop, the thread pool, non-blocking I/O, microtasks.
When to Use This Model: Reasoning about latency spikes, choosing between worker_threads/child_process/cluster, explaining to a teammate why a for loop took down the API, and sizing thread-pool-bound workloads (crypto, compression, some fs/DNS calls).
Limitations/Trade-offs: Great for I/O-bound, high-concurrency workloads; a poor default for CPU-heavy batch work unless you deliberately opt into parallelism.
Related Topics: Event loop phases, libuv internals, worker threads, Node vs. Bun vs. Deno.
Before Node (2009), server-side JavaScript wasn't really a thing, and most server platforms handled concurrency by spinning up a thread (or process) per connection. That model is simple to reason about but expensive: idle threads still consume memory and get scheduled by the OS, so concurrency is capped by how many threads a machine can hold.
Node's creator, Ryan Dahl, inverted that: instead of one thread per connection, Node uses one JavaScript thread for everything, and never lets that thread sit idle waiting on I/O. When your code asks to read a file, query a database, or make an HTTP request, Node hands the waiting part off to the operating system (or a helper thread), immediately returns control to the JS thread, and calls your code back only once the result is ready.
A useful mental model: picture a single air-traffic controller who never personally flies a plane. Every takeoff and landing (an I/O operation - a file read, a socket write) is handled by the pilots and ground crew (the OS and libuv) while the controller keeps directing new traffic. The moment a plane lands, the controller gets a radio call and reacts to it - but if the controller ever has to step away and personally solve a math problem (synchronous CPU work), every other plane waits until that's done. Node is fast at directing traffic and completely blocked while doing arithmetic.
Two pieces make this possible:
V8 - the same JavaScript engine Chrome uses. It compiles your JS to machine code, runs it, and manages memory/garbage collection. V8 knows nothing about servers, files, or sockets.
libuv - a C library that gives Node cross-platform async I/O, timers, and a thread pool. It's the layer that actually talks to the operating system's async primitives (epoll on Linux, kqueue on macOS/BSD, IOCP on Windows) and drives the event loop that ties everything together.
The event loop is not a queue you push callbacks onto directly - it's a fixed set of phases that libuv cycles through, each responsible for a different kind of pending work: timers (setTimeout/setInterval), pending I/O callbacks, poll (retrieving new I/O events), check (setImmediate), and close callbacks. Between almost every step, Node drains the microtask queue - resolved Promises and queueMicrotask callbacks - before moving on, which is why Promise.resolve().then() reliably runs before a setTimeout(fn, 0) even though both look like "run soon."
Most I/O in Node maps directly onto an OS-level async API, so libuv can hand it off with no extra threads involved - the kernel notifies libuv, libuv notifies the event loop, the event loop calls your JS. But a handful of operations have no async OS equivalent - some fs calls, crypto.pbkdf2, DNS lookups, and zlib compression - so libuv runs those on a small thread pool, four threads by default, sized via UV_THREADPOOL_SIZE. This distinction matters operationally: main-thread saturation shows up as CPU pegged at 100%; thread-pool saturation shows up as requests silently queueing while CPU looks fine.
You can observe this model directly rather than just reason about it - perf_hooks exposes the event loop's own health as a metric:
A rising p99 here means something - a synchronous loop, a huge JSON.parse, a thread-pool backlog - is keeping the single JS thread from getting back to the event loop promptly, which delays every other pending callback, not just the slow one's.
Because concurrency comes from not blocking, the model's failure mode is specific and predictable: any synchronous, CPU-bound operation blocks the entire process, not just the request that triggered it. A single for loop hashing data, a large synchronous JSON.parse, or a native addon that calls back into JS synchronously will stall every other in-flight request on that process, regardless of how many clients are connected or how "async" the rest of the code looks.
Node deliberately does not solve this for you - it gives you explicit tools to opt into parallelism when you need it:
Approach
Strength
Weakness
Best Fit
worker_threads
Runs CPU-bound JS off the main thread, in-process
Message-passing overhead; not full isolation
Hashing, image/data processing, parsing large payloads
child_process
Full OS-level isolation; can run non-Node tools
Higher overhead; no shared memory by default
Shelling out to CLIs, running a different runtime
cluster module
Uses multiple cores for HTTP on one machine
Each worker has its own memory/event loop
Multi-core throughput without an orchestrator
Horizontal replicas / external queue
Scales past one machine; decouples retryable work
Network hop; needs infra (LB, queue)
Kubernetes/cloud deployments, long or retriable jobs
This is also why Node's fit is workload-shaped, not universal: it excels at I/O-bound, high-concurrency work - APIs, proxies, streaming, real-time gateways - where most time is spent waiting on a network or disk, not computing. It's a weaker default for CPU-heavy batch work (video encoding, large numeric simulations) unless that work is explicitly moved off the main thread. Newer JS runtimes like Bun and Deno keep the same fundamental event-loop model (they still embed V8 or a V8-like engine) - they differ in tooling, startup time, and built-in APIs, not in this core concurrency shape; see Node.js vs. Bun vs. Deno for that comparison.
The model has stayed remarkably stable across Node's history: Node 24 ships a newer V8, a stable built-in fetch, and an improved node:test, but the V8-plus-libuv-plus-single-thread architecture underneath is unchanged from Node's earliest releases. In production, event-loop health (delay/utilization metrics like the snippet above) is one of the highest-signal things to put on a dashboard, because it degrades before error rates typically do.
"Node is single-threaded, full stop." Only your JavaScript is confined to one thread. libuv's thread pool, the OS's own I/O handling, and V8's garbage collector can all use additional threads behind the scenes - "single-threaded" describes where your callbacks run, not the whole process.
"async/await creates a new thread to run the awaited work." It doesn't. await just pauses that function and hands control back to the event loop; when the underlying operation completes, the continuation resumes as a microtask on the same JS thread.
"setImmediate runs immediately, before other pending work." It runs in the check phase, after the poll phase's I/O callbacks - it's ordered relative to the event loop's phases, not a synonym for "as soon as possible."
"More CPU cores automatically mean more Node throughput." A single Node process uses one core for JavaScript no matter how many cores the machine has. Using the rest requires an explicit choice - cluster, multiple processes, or worker_threads.
"If my code uses async/await everywhere, it can't block the event loop." Any synchronous statement inside an async function still runs synchronously. async only changes how the function's result is delivered, not whether its code can stall the thread while it runs.
JavaScript execution within a single Node process is single-threaded - your callbacks all run on one thread. libuv uses additional threads internally (its thread pool, plus OS-level async handling), but none of that JavaScript-visible work runs in parallel with your code.
What does libuv actually do?
libuv is the C library that gives Node its event loop, cross-platform async I/O (wrapping epoll/kqueue/IOCP), timers, and a thread pool for the handful of operations with no async OS equivalent. V8 runs your JavaScript; libuv is what makes that JavaScript able to do non-blocking I/O at all.
Why does one slow request slow down every other request?
Every request handler in a Node process shares the same single JavaScript thread. A synchronous, CPU-bound section of code - a big loop, a large JSON.parse - occupies that thread completely, so no other handler's callback can run until it finishes, even if those other requests were otherwise ready to complete instantly.
How many threads does Node actually use by default?
One thread runs your JavaScript. libuv additionally runs a thread pool (four threads by default, configurable via UV_THREADPOOL_SIZE) for operations like some fs calls, crypto.pbkdf2, DNS lookups, and zlib - plus whatever threads V8's garbage collector and the OS use internally.
Does `await` spin up a new thread?
No. await pauses the current function and returns control to the event loop. When the awaited operation finishes, its continuation is scheduled as a microtask that runs on the same original JS thread - no new thread is created for the await itself.
What's the actual difference between V8 and Node?
V8 is just the JavaScript engine - the same one Chrome uses - and knows nothing about files, sockets, or servers. Node is V8 plus libuv (event loop, async I/O, thread pool) plus built-in modules (fs, http, crypto, …), the module loader, and npm - the pieces that turn "a JS engine" into "a server runtime."
Why can a CPU-bound loop take down an entire API, even under load balancing?
Load balancing distributes requests across processes (or machines), but within a single process every request still shares one JS thread. A CPU-bound loop on that thread blocks all requests currently routed to that specific process - the fix is offloading the work (worker_threads), not just adding more load-balanced instances, unless you also route around the affected process.
When should I reach for `worker_threads` versus `cluster` versus `child_process`?
worker_threads - CPU-bound JavaScript that needs to stay in the same process and can share memory via SharedArrayBuffer.
cluster - using multiple cores for HTTP traffic on a single machine, with each worker as its own independent process.
child_process - running a separate program or a different runtime entirely, where full OS-level isolation is worth the overhead.
Does Node's threading model differ meaningfully from Bun or Deno?
Not at the core level - both still run JavaScript on a single thread per process with a non-blocking I/O event loop. The differences are mostly tooling, startup performance, and built-in APIs, not the underlying concurrency shape.
What's the practical difference between thread-pool saturation and main-thread blocking?
Main-thread blocking (a synchronous loop) shows up as CPU pegged near 100% with everything stalled. Thread-pool saturation (too many concurrent pbkdf2/fs/zlib calls) shows up as requests silently queueing behind the pool's fixed size, often with CPU looking normal - the symptoms point to different fixes.
How do I observe event-loop health in production?
import { monitorEventLoopDelay } from 'node:perf_hooks';const h = monitorEventLoopDelay({ resolution: 20 });h.enable();
Sample h.percentile(99) on an interval and ship it to your metrics pipeline - a rising p99 event-loop delay is one of the earliest signals that something synchronous is stalling the process, often before error rates move.
Has this model changed in recent Node versions?
No - Node 24 ships a newer V8, a stable built-in fetch, and an improved node:test, but the V8-plus-libuv-plus-single-JS-thread architecture is the same one Node has used since its earliest releases.
Why would anyone choose Node over a threaded server runtime?
Non-blocking I/O is a strong default for high-concurrency, I/O-bound workloads - APIs, reverse proxies, streaming, real-time gateways - because idle connections cost almost nothing while waiting. Runtimes that default to a thread (or process) per request instead pay memory and scheduling overhead for every idle connection, which shows up as a lower practical concurrency ceiling on the same hardware.