Open-Source Wikis

/

Node.js

/

Node.js

/

Architecture

nodejs/node

Architecture

Node.js is a host program that embeds the V8 JavaScript engine, drives an event loop using libuv, and exposes platform capabilities (file system, networking, crypto, child processes, …) to JavaScript through C++ bindings. This page explains the layered architecture and how a process moves from int main() to "your index.js is running".

Layered architecture

graph TD
    subgraph Userland
        UJS["User JS (CommonJS / ESM / TypeScript via amaro)"]
    end
    subgraph "Public JS API (lib/*.js)"
        Public["fs / http / crypto / net / stream / worker_threads / test / sqlite ..."]
    end
    subgraph "Internal JS (lib/internal/**)"
        Internal["bootstrap, modules, streams, errors, http2, crypto, test_runner, ..."]
        Bindings["internalBinding('xxx') -> C++"]
    end
    subgraph "C++ host (src/**)"
        Env["Environment / Realm"]
        Wraps["AsyncWrap / HandleWrap / BaseObject"]
        Modules["node_*.cc binding modules (fs, crypto, http2, sqlite, ...)"]
    end
    subgraph "Native dependencies (deps/**)"
        V8[V8 engine]
        UV[libuv event loop]
        OpenSSL[OpenSSL / ncrypto]
        LLHTTP[llhttp]
        NGHTTP2[nghttp2]
        NGTCP2[ngtcp2 + nghttp3]
        ADA[ada URL parser]
        ICU[ICU]
        SIMDJSON[simdjson]
        SQLITE[SQLite]
        UVWASI[uvwasi]
        UNDICI[undici fetch/HTTP]
        AMARO[amaro TS stripper]
    end

    UJS --> Public
    Public --> Internal
    Internal --> Bindings
    Bindings --> Modules
    Modules --> Env
    Modules --> Wraps
    Env --> V8
    Wraps --> UV
    Modules --> OpenSSL
    Modules --> LLHTTP
    Modules --> NGHTTP2
    Modules --> NGTCP2
    Public --> ADA
    Modules --> ICU
    Modules --> SIMDJSON
    Modules --> SQLITE
    Modules --> UVWASI
    Public --> UNDICI
    Internal --> AMARO

The layering is intentional: user code can only reach C++ through the public JS API. The public modules in lib/*.js re-export from lib/internal/**, which call into the runtime via internalBinding(name). Only modules listed in lib/internal/bootstrap/realm.js (the processBindingAllowList and the legacy wrapper list) are exposed through process.binding() at all; the internalBinding namespace is hidden from user code.

Process lifecycle

sequenceDiagram
    autonumber
    participant OS
    participant main as src/node_main.cc
    participant node_cc as node::Start (src/node.cc)
    participant Init as InitializeOncePerProcess
    participant Inst as NodeMainInstance
    participant Realm as Realm::BootstrapRealm
    participant Bootstrap as lib/internal/bootstrap/{realm,node}.js
    participant Pre as lib/internal/process/pre_execution.js
    participant Main as lib/internal/main/<entry>.js
    participant User as user code

    OS->>main: argv, envp
    main->>node_cc: node::Start(argc, argv)
    node_cc->>Init: parse CLI, init V8, init OpenSSL, init ICU
    Init->>Inst: build NodeMainInstance with snapshot
    Inst->>Realm: enter principal Realm
    Realm->>Bootstrap: deserialize snapshot OR run realm.js + node.js
    Bootstrap->>Pre: setupPreExecution(...)
    Pre->>Main: select run_main_module / repl / eval / worker_thread / test_runner
    Main->>User: require/import the user entry, run event loop
    User-->>OS: exit code

Steps 4–5 are the embedded V8 startup snapshot. Node ships a snapshot generated at build time by tools/snapshot/ and SnapshotBuilder::Generate() in src/node_snapshotable.cc, so the bootstrap scripts do not actually re-run on every start — their resulting V8 heap is deserialized. Pass --no-node-snapshot to take the slow path.

The selectable main scripts live in lib/internal/main/:

Main script Selected when
lib/internal/main/run_main_module.js Default — node app.js
lib/internal/main/eval_string.js node -e "..."
lib/internal/main/eval_stdin.js node reading from stdin
lib/internal/main/repl.js Interactive REPL
lib/internal/main/check_syntax.js node --check
lib/internal/main/inspect.js node inspect … (inspector CLI)
lib/internal/main/print_help.js node --help
lib/internal/main/test_runner.js node --test
lib/internal/main/watch_mode.js node --watch
lib/internal/main/worker_thread.js Inside a Worker thread
lib/internal/main/embedding.js Embedder-driven entry
lib/internal/main/mksnapshot.js Building the startup snapshot
lib/internal/main/prof_process.js --prof-process

The full selection logic is in src/node.cc (search for LoadEnvironment) and lib/internal/process/pre_execution.js.

Realm and Environment model

Node has two C++ types that organize state:

  • node::Environment (src/env.h, src/env.cc) — process-/thread-wide state for one Node "instance". A worker thread has its own Environment. Shared infrastructure lives here: the libuv loop, the inspector agent, the per-isolate options, performance state, exit handlers.
  • node::Realm (src/node_realm.h, src/node_realm.cc) — the JS-side execution context. The "principal realm" is created when an Environment boots; additional ShadowRealms and Realm-per-Worker contexts use this same primitive. Realms own bindings, the per-realm builtin loader, and the JS process object.
graph TD
    Iso[V8 Isolate] --> Env[node::Environment]
    Env --> PR[Principal Realm]
    Env --> SR[ShadowRealm 1..N]
    Env --> WT[Worker thread Env]
    PR --> Bindings1["internalBinding(...) -> Realm-scoped storage"]
    PR --> JsProcess[process object]
    PR --> EventLoop[libuv loop]
    SR --> Bindings2[Per-Realm bindings]

See primitives › environment-and-realm for details, including the difference between an IsolateData, an Environment, and a Realm.

Async I/O: libuv handles, AsyncWrap, and the JS callback shape

Almost every I/O object in Node is a thin JS class wrapping a *Wrap C++ object that wraps a libuv handle or request:

graph LR
    JSCls["JS class (e.g. net.Socket)"] --> Wrap["C++ TCPWrap : ConnectionWrap : StreamBase : AsyncWrap"]
    Wrap --> UV["uv_tcp_t"]
    UV --> Loop[libuv event loop]
    Loop --> CB["uv callback -> Wrap::OnEvent"]
    Wrap --> JSCB["MakeCallback() -> JS"]
    JSCB --> JSCls
  • BaseObject (src/base_object.h) is the common ancestor for any C++ object that has a JS wrapper; it knows how to be persisted across snapshots and how to be torn down at Realm cleanup.
  • AsyncWrap (src/async_wrap.h) attaches an async-resource identity used by async_hooks, AsyncLocalStorage, and diagnostic channels. It owns the MakeCallback machinery that runs JS callbacks while accounting for process.nextTick, microtasks, and the async stack.
  • HandleWrap (src/handle_wrap.h) and ReqWrap (src/req_wrap.h) are the two libuv flavors: long-lived handles vs. one-shot requests.
  • StreamBase (src/stream_base.h) is the C++ side of Node's stream contract used by sockets, pipes, TLS, and HTTP/2.

A typical socket.write(buf, cb) call goes JS → StreamBase::Writevuv_write → libuv → OnAfterWriteMakeCallback → JS callback.

See primitives › async-wrap-and-handle-wrap and systems › streams.

Module system: CommonJS, ESM, and the loader split

Node implements two parallel module loaders that share a resolver and translators:

  • CommonJS in lib/internal/modules/cjs/ (notably loader.js exporting the Module class).
  • ECMAScript Modules in lib/internal/modules/esm/, structured as loader.js (the in-thread loader), hooks.js (the customization-hooks worker bridge), resolve.js, load.js, translators.js, module_job.js.
  • The ESM loader can hand resolution off to a worker (lib/internal/modules/esm/worker.js) so user-supplied hooks run in isolation.
  • TypeScript stripping is implemented by integrating the amaro Rust crate, exposed via lib/internal/modules/typescript.js.

See systems › module-loaders.

Permission Model

A capability-style sandbox lives behind --permission. Each domain (fs, child_process, worker, wasi, inspector, addon, net, ffi) has its own *_permission.{cc,h} under src/permission/, registered in src/permission/permission.cc. JS hooks live in lib/internal/process/permission.js. See systems › permission-model.

Diagnostics surface

Node exposes a layered diagnostics stack:

  • inspector (Chrome DevTools protocol) — src/inspector/, src/inspector_* and lib/inspector.js.
  • async_hooks and AsyncLocalStoragelib/async_hooks.js, lib/internal/async_hooks.js, lib/internal/async_local_storage/, backed by AsyncWrap.
  • diagnostics_channellib/diagnostics_channel.js and src/node_diagnostics_channel.{cc,h}.
  • perf_hookslib/perf_hooks.js, lib/internal/perf/, src/node_perf.{cc,h}.
  • trace_eventslib/trace_events.js, src/tracing/ (perfetto-based when enabled).
  • reportsrc/node_report.cc (process.report).

See systems › diagnostics.

Build pipeline at a glance

graph LR
    cfg[configure.py] --> gypi[config.gypi]
    gypi --> gyp[node.gyp / common.gypi]
    gyp --> ninja[Ninja or Make]
    deps[deps/v8, libuv, openssl, ...] --> ninja
    src[src/**] --> ninja
    js2c[tools/js2c.cc] --> embedded[node_javascript.cc]
    embedded --> ninja
    ninja --> binary[node binary]
    binary --> mksnap[node_mksnapshot]
    mksnap --> snap[snapshot blob]
    snap --> binary

The tools/js2c.cc step compiles every .js and .mjs under lib/ and selected deps/ directories into a C++ source file that ships inside the node binary. That is why process.moduleLoadList reports module loads without ever touching the disk. See Vendored deps and build and systems › bootstrap-and-startup.

Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.

Architecture – Node.js wiki | Factory