How it is built
Architecture overview
A server JavaScript runtime is a JavaScript engine embedded in a host program. The engine (V8 or JavaScriptCore) knows the language: objects, promises, a garbage collector, a JIT. It knows nothing about files, sockets or timers. The host adds those as native functions and drives the engine from an event loop: it waits for the operating system, calls a JavaScript callback when something is ready, and after each callback runs the queued promise jobs. Blocking work that has no non-blocking kernel interface goes to a small thread pool and comes back as an event. The one idea is a single JavaScript thread that never blocks, fed by an event loop that does all the waiting.
Node.js, Deno and Bun are three answers to the same host problem. Node pairs V8 with the C library libuv. Deno is written in Rust on V8 and Tokio. Bun is written in Rust around JavaScriptCore. The sections below follow the parts that every one of them has: engine, event loop, native I/O, modules, startup, and operational controls.
Parts:
- Engine and embedding — runs JavaScript, owns the heap and the promise jobs — V8 or JavaScriptCore (see the jit-vm system), Memory ordering for the shared-memory API
- Event loop and timers — waits for readiness, fires callbacks — javascript-event-loop (no page yet), epoll event loop, Hierarchical timing wheel (the alternative to a timer heap), cooperative-scheduling (no page yet), d-ary heap
- Native I/O and the worker pool — sockets, files, DNS, blocking calls — io_uring, Zero-copy I/O, Buffer pools for zero-allocation I/O, Work-stealing deque, Epoch-based reclamation
- Network protocols — HTTP, WebSocket, TLS — HTTP/1.1 message parsing, WebSockets, TLS 1.3, Mutual TLS, AEAD ciphers, ECDH key exchange, HKDF, SHA-2 and BLAKE3, x509-certificates (no page yet)
- Module system and packages — finds, links and orders code — module-resolution (no page yet), Linking and relocation for native addons, dependency-version-resolution (no page yet), semver-range-matching (no page yet), Topological sort, BFS and DFS
- Startup — gets from process start to the first line of user code quickly — heap-snapshot-startup (no page yet), mmap and the page cache
- Operations — restart, drain, reload — Process supervision, Graceful draining, File system watching
- Reliability and verification — keeps the runtime and its state recoverable, finds parser and loop bugs, measures tail latency — Coverage-guided fuzzing, Deterministic simulation testing, hdr-histogram (no page yet), Primary-backup replication, Point-in-time recovery
- Support structures — Swiss table, Slab allocator, Virtual memory and the TLB, SIMD programming (parsers, string scanning)
JS thread (one per isolate) libuv-style host
┌───────────────────────────────┐ ┌─────────────────────────────────────┐
│ engine: heap, JIT, GC │ native │ event loop: timers ─► poll ─► check │
│ call stack ─► drain microtasks│◄──────►│ epoll/kqueue/IOCP, io_uring (files) │
│ promise jobs queue │ calls │ thread pool (default 4): fs, DNS │
└──────────────▲────────────────┘ └──────────────┬──────────────────────┘
│ callbacks │ readiness / completion
modules, snapshot, permissions sockets, files, child processes
How the parts fit together
Engine and embedding
An embedder creates an isolate, "a VM instance with its own heap", and contexts inside it, and touches every JavaScript object through handles because the garbage collector may move objects (V8 embedder's guide). One isolate is used by one thread at a time; Node's worker threads each run their own JavaScript engine instance, which is how CPU-bound work gets parallelism, with memory sharing only through transferred ArrayBuffers or SharedArrayBuffers (Node.js worker_threads documentation). The Memory ordering rules matter at that boundary: atomics on shared buffers are the only synchronization JavaScript has. Everything about tiers, inline caches, deoptimization and the collector is the jit-vm system, which sits inside this box. The contract the host adds: native functions called from JavaScript run on the JavaScript thread and must return quickly.
Promise jobs and the event loop
The language specification only says that a settled promise enqueues a job through a host hook, HostEnqueuePromiseJob; when jobs run is the host's decision (ECMAScript specification). V8 exposes this as a microtask policy: MicrotasksPolicy::kExplicit, kScoped or kAuto (V8 include/v8-microtask.h). Browsers run a microtask checkpoint after each task (HTML specification). Node runs the same promise queue and adds its own process.nextTick queue: each time the next-tick queue is drained, the microtask queue is drained immediately after, so in CommonJS a nextTick callback runs before a promise callback, while in ES modules, which already run inside the microtask queue, the order flips (Node.js process documentation). The block javascript-event-loop (no page yet) describes the queues; the host-level loop under it is libuv's.
libuv's loop iterates in a fixed order: update the loop's notion of "now", run due timers, run pending callbacks, run idle and prepare handles, compute a poll timeout, block for I/O, run I/O callbacks, run check handles (setImmediate), run close callbacks (libuv design overview). The blocking step is an epoll event loop on Linux, kqueue on macOS and the BSDs, and IOCP on Windows. The timeout in the blocking step is the time until the next timer, which links the two halves: libuv keeps timers in a min-heap (src/timer.c, heap-inl.h), a Hierarchical timing wheel is the alternative when millions of timeouts are armed and cancelled. A d-ary heap (d = 4) is the cache-friendlier variant of that timer min-heap: with many pending timeouts, half the depth and siblings in one cache line make each insert and pop-min cheaper, and the poll timeout is still read from the heap's root. cooperative-scheduling (no page yet) is the mental model for the JavaScript side: tasks yield only at await and at the end of a callback.
Native I/O and the worker pool
Sockets are non-blocking and polled. Files are different: the libuv design overview says there are no platform-specific file I/O primitives it could rely on, so file system operations and DNS lookups (getaddrinfo, getnameinfo) run on a global thread pool, and user code can queue work there too. The default pool size is 4, settable with UV_THREADPOOL_SIZE up to 1024 (libuv thread pool documentation). Two refinements exist. On Linux libuv can now route file requests through io_uring, so some file work needs no pool thread (src/unix/linux.c and the ChangeLog show io_uring file requests and their cancellation). And the data path can avoid copies: Zero-copy I/O and Buffer pools for zero-allocation I/O are the blocks behind a Buffer that points at kernel-read bytes without a JavaScript-heap copy.
The Rust runtimes use a different pool. Deno states it is built on V8, Rust and Tokio (Deno README); Tokio's multi-thread scheduler is the typical home of a work-stealing deque, with epoch-based reclamation as one of the techniques used inside lock-free structures. The contract to JavaScript is unchanged: a completion becomes one callback on the JavaScript thread.
Network protocols
HTTP is parsed natively and the parsed pieces cross into JavaScript as a small number of strings and objects; Node's parser is llhttp, a port of http_parser to the llparse generator (nodejs/llhttp). HTTP/1.1 message parsing is the block; scanning for delimiters is where SIMD programming helps. WebSockets layer on the same connection after an upgrade. TLS 1.3 with its building blocks (AEAD, ECDH, HKDF, hashes) is not written by the runtime; Node's TLS API exposes the settings of its TLS library, such as cipher lists and a security level (Node.js tls documentation). Certificate chains and validation are x509-certificates (no page yet); client authentication is Mutual TLS.
Module system and packages
Code is found by name: require and import walk node_modules folders and package exports maps (Node.js modules and packages documentation), a step the graph calls module-resolution (no page yet). Modules form a graph with cycles; evaluation order is a depth-first post-order (BFS and DFS, Topological sort). A package manager picks versions that satisfy the ranges (dependency-version-resolution and semver-range-matching, both no page yet) and writes them into node_modules; the npm folder layout is documented in npm's folders page. Native addons are shared libraries loaded at run time, a case of Linking and relocation, and cross the boundary through an ABI-stable C API in Node (Node-API documentation); the platform's calling-conventions-abi (no page yet) applies below that.
Startup
A JavaScript runtime starts a fresh heap and runs bootstrap code before user code. The cost is cut by serializing an initialized heap and loading it (heap-snapshot-startup, no page yet). V8 supports custom startup snapshots (V8 blog, Custom startup snapshots); Node exposes them with --build-snapshot and the v8.startupSnapshot API, offers a module compile cache through NODE_COMPILE_CACHE, and can pack an application into a single executable (Node.js CLI and single-executable-application documentation). The snapshot file is read through the OS page cache (mmap and the page cache), so repeated starts are mostly page-cache hits. The constraint is that everything in a snapshot must be serializable: open sockets, timers and native handles cannot be.
Operations: supervise, drain, reload
One JavaScript thread means one crash takes out the process, so servers run several processes under a supervisor (Process supervision); Node ships a cluster module for this, and the usual alternative is the platform's init system. On a deploy or scale-in, the process stops accepting, finishes in-flight requests and then exits (Graceful draining). During development node --watch restarts on file changes (File system watching describes why the events are hints, not facts). Permissions are the other operational control: Deno runs with no file, network, environment or subprocess access unless granted by flags or prompts, and says that code at the same privilege level is not limited (Deno security documentation); Node has an opt-in --permission model; workerd (Cloudflare) isolates each Worker to the resources configured for it, but its README says it needs an outer sandbox for hostile code.
Reliability and verification
The runtime itself is stateless: a process that dies loses only in-flight requests, so durability belongs to the store the application writes to. Where that store is a primary with standbys (Primary-backup replication), the runtime's contract is to treat the primary as an address that can change: connections drop, the client reconnects to the new primary, and non-idempotent requests carry a key so a retry is safe. The same applies to Point-in-time recovery: an application bug that writes bad data through the runtime is repaired by rebuilding the store to the instant before it, not by anything inside the host.
Two testing blocks target the host's own parsers and loop. Coverage-guided fuzzing feeds mutated byte streams to the HTTP/1.1 parser, the WebSocket frame decoder and the TLS-facing glue, keeping any input that reaches a new code edge; these are the places where untrusted bytes first meet native code. Deterministic simulation testing runs the event loop with a simulated clock, sockets and disk under a seeded scheduler, so ordering bugs between timers, promise jobs and I/O completions replay from a seed. hdr-histogram (no page yet) records request latency and event-loop lag per callback with bounded relative error, so p99 and p999 are read from one fixed-size structure and drain or restart decisions can look at tail latency instead of the mean.
Data and control flow
One HTTP request in Node (single process, Linux):
- The listening socket becomes readable; the poll step of the loop returns and calls the connection callback with the new socket.
- The next poll reports data on the socket; the native read fills a buffer and the HTTP parser splits it into method, path, headers and body chunks.
- JavaScript runs the request handler on the one JavaScript thread; any
awaitreturns control and the loop goes on serving other sockets. Time goes into the handler, JSON handling and garbage collection, not into waiting. - A database call or a file read is submitted: a socket write for the database, a thread-pool job (or io_uring request) for the file. The handler is suspended on a promise.
- The result arrives as an event; libuv calls the native completion, which resolves the promise; after the callback returns, the host drains the next-tick queue and then the microtask queue, so the handler continues before the loop looks at I/O again.
- The response is written to the socket (non-blocking; a partial write waits for the next writable event) and the loop moves on.
Process start with a snapshot:
- The binary maps the snapshot and deserializes the heap (page-cache reads).
- The host re-attaches native handles (stdout, the event loop) that cannot be in a snapshot.
- The entry point runs; the first request is served without bootstrap compile time.
Scaling
In order:
- The JavaScript thread. Handlers that do CPU work block everything on that isolate. Levers: move work to worker threads, shrink per-request work (parsing, serialization), keep the code in the optimized tier.
- The thread pool. File, DNS and crypto work share four threads by default; a slow disk or DNS stalls unrelated requests. Levers: raise
UV_THREADPOOL_SIZE, io_uring for files, avoid sync file calls on the hot path. - One process per core. A single event loop uses one core. Levers: run N processes behind a listening socket (cluster, containers), keep state out of the process, drain on deploy.
- Memory. Heap limits and buffers off the heap; the garbage collector pauses grow with heap size. Levers: smaller live heap, streams instead of whole bodies, worker resource limits (Node lets you set heap limits per worker).
- Connection count. Per-connection cost (parser state, TLS state, buffers). Levers: buffer pools, fewer copies, offload TLS to a proxy.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Blocked event loop | all requests stall together, latency spikes | CPU-heavy or synchronous work on the JavaScript thread (Node's guide: "Don't block the Event Loop") | move to a worker, split the work, avoid sync APIs and catastrophic regular expressions |
| Starved thread pool | file and DNS calls take seconds while CPU is idle | more concurrent pool jobs than 4 threads | raise the pool size, route heavy work elsewhere |
| Microtask starvation | timers and I/O callbacks never run | a promise chain or nextTick loop that re-enqueues itself, since the queues are drained completely | bound the recursion; yield with a macrotask (setImmediate) |
| Unhandled rejection or exception | process exits or leaks state | the single thread has no isolation between requests | handlers with explicit error paths, a supervisor that restarts, health checks |
| Memory growth | rising RSS, long GC pauses, out-of-memory kill | closures retaining request data, unbounded caches, large buffers | heap snapshots and profiling, bounded caches, streaming |
| Slow start | autoscaled instances serve their first requests late | bootstrap and compile on every start | startup snapshot, compile cache, single executable |
| Unbounded backpressure | memory grows when a client reads slowly | writing without checking the writable signal | honour stream backpressure, pause the source |
| Native addon crash | segfault, no JavaScript stack | ABI mismatch or a bug below the engine | ABI-stable addon API, version the binaries, crash reports |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| Node.js | V8 plus libuv plus a C++ binding layer | nodejs/node | MIT (per LICENSE) | event loop in libuv; worker threads, cluster, startup snapshots |
| libuv | cross-platform event loop, thread pool and I/O library written for Node.js | libuv/libuv | MIT | epoll, kqueue, event ports, IOCP; io_uring for Linux file requests |
| Deno | JavaScript, TypeScript and WebAssembly runtime on V8, Rust and Tokio | denoland/deno | MIT | permissions on by default; core engine glue merged into this repo |
| Bun | runtime, bundler, test runner and package manager in one binary | oven-sh/bun | MIT, plus LGPL-2 for the statically linked JavaScriptCore (per its LICENSE.md) | README: written in Rust, powered by JavaScriptCore |
| workerd | Cloudflare's runtime for Workers on V8 | cloudflare/workerd | Apache-2.0 | isolates per Worker, capability bindings |
| llhttp | Node's HTTP parser | nodejs/llhttp | MIT | generated with llparse |
The alternative: a thread-per-request or goroutine/async-task model in another language needs no single-thread discipline but pays per-connection memory; the JavaScript model wins when handlers are mostly waiting on I/O and teams share code with the browser.
Sources
- libuv: Design overview, Thread pool work scheduling;
src/timer.c,src/unix/linux.candChangeLogin libuv/libuv. - Node.js documentation: The event loop, timers and nextTick, Don't block the event loop, process, worker_threads, command-line options, v8 module, single executable applications, tls, modules, packages, Node-API, cluster.
- V8: Getting started with embedding, Custom startup snapshots;
include/v8-microtask.hin v8/v8. - ECMAScript: HostEnqueuePromiseJob; HTML Standard, event loops and microtask checkpoints.
- Deno: README, security and permissions. Bun: README and LICENSE.md. workerd: README. npm: folders.