Read OSS

From main() to Your First Line of JavaScript

Advanced

Prerequisites

  • Article 1: architecture-overview (directory structure and build system)
  • Basic understanding of V8 concepts (Isolate, Context)
  • Familiarity with event loop concepts

From main() to Your First Line of JavaScript

When you type node app.js, roughly 200 milliseconds elapse before your first line of JavaScript executes. In that time, Node.js initializes a C++ runtime, creates a V8 isolate, optionally deserializes a heap snapshot, runs a chain of JavaScript bootstrap scripts that set up the global environment, and finally dispatches to one of 13 different entry modes depending on your CLI flags. Understanding this startup sequence is essential for anyone working on Node.js internals, building embedder applications, or debugging startup performance.

C++ Entry Point Chain

Everything begins in src/node_main.cc. On Unix, it's a straightforward main() that immediately calls node::Start(). On Windows, it's wmain() — the wide-character variant that converts wchar_t* arguments to UTF-8 before calling the same node::Start():

sequenceDiagram
    participant OS as Operating System
    participant NM as node_main.cc
    participant NC as node.cc
    participant NMI as NodeMainInstance

    OS->>NM: main(argc, argv)
    Note over NM: Windows: wmain() converts wchar_t to UTF-8
    NM->>NC: node::Start(argc, argv)
    NC->>NC: StartInternal(argc, argv)
    NC->>NC: uv_setup_args(argc, argv)
    NC->>NC: InitializeOncePerProcessInternal()
    Note over NC: Parse CLI args, init OpenSSL, V8, ICU
    NC->>NC: LoadSnapshotData()
    NC->>NMI: NodeMainInstance(snapshot, loop, platform, args)
    NMI->>NMI: Run()

The real work happens in StartInternal(). This function is the spine of the startup sequence. It calls uv_setup_args() to let libuv manage process.title, then calls InitializeOncePerProcessInternal() — the heavyweight initializer.

InitializeOncePerProcessInternal() does the following in order:

  1. Parses debug environment variables
  2. Runs platform initialization (PlatformInit)
  3. Parses CLI arguments and environment variables (including NODE_OPTIONS)
  4. Optionally maps static code to large pages for performance
  5. Handles early-exit flags (--version, --help, --v8-options)
  6. Initializes OpenSSL (if compiled in)
  7. Initializes ICU for internationalization
  8. Initializes the V8 platform and engine

Tip: If you ever need to add a new CLI flag to Node.js, start in src/node_options.h where the option is defined, then trace into InitializeNodeWithArgsInternal() inside node.cc to see how it's parsed.

V8 Isolate Creation and Snapshot Loading

After process-level initialization, StartInternal() loads snapshot data. Node.js ships with a pre-built V8 heap snapshot that captures the state of the JavaScript environment after bootstrap — this dramatically speeds up startup by avoiding the need to re-execute bootstrap scripts.

flowchart TD
    START["StartInternal()"] --> SNAP_CHECK{"--no-node-snapshot?"}
    SNAP_CHECK -->|Yes| NO_SNAP["snapshot_data = nullptr"]
    SNAP_CHECK -->|No| LOAD["LoadSnapshotData()"]
    LOAD --> CUSTOM{"--snapshot-blob set?"}
    CUSTOM -->|Yes| READ_FILE["Read from custom file"]
    CUSTOM -->|No| EMBED["GetEmbeddedSnapshotData()"]
    EMBED --> BUILD{"Embedded snapshot exists?"}
    BUILD -->|Yes| USE_SNAP["Use embedded snapshot"]
    BUILD -->|No| NO_SNAP
    
    NO_SNAP --> CREATE_ISOLATE["NodeMainInstance constructor<br/>NewIsolate() + CreateIsolateData()"]
    USE_SNAP --> CREATE_ISOLATE
    READ_FILE --> CREATE_ISOLATE

The NodeMainInstance constructor creates the V8 isolate with the snapshot data, sets up the array buffer allocator, and creates IsolateData — the per-isolate state object that holds the libuv event loop, platform pointer, and pre-interned strings.

At build time, a separate binary called node_mksnapshot runs the bootstrap scripts and serializes the resulting V8 heap. At runtime, NewIsolate() passes this snapshot data to V8, which deserializes the heap instead of running all the JavaScript from scratch. This is why Node.js starts in ~30ms rather than ~200ms.

Environment and Realm Creation

With the isolate ready, NodeMainInstance::Run() creates the main Environment and enters it:

sequenceDiagram
    participant NMI as NodeMainInstance
    participant ENV as Environment
    participant REALM as PrincipalRealm
    participant JS as JavaScript Bootstrap

    NMI->>NMI: Run() — acquire Locker, HandleScope
    NMI->>ENV: CreateMainEnvironment()
    Note over ENV: Creates Environment + PrincipalRealm
    ENV->>REALM: PrincipalRealm(env, context)
    Note over REALM: If no snapshot: CreateProperties()
    NMI->>NMI: LoadEnvironment(env)
    Note over NMI: Triggers StartExecution()
    NMI->>NMI: SpinEventLoopInternal(env)

The Environment object (defined in src/env.h) is the central state container — it holds the V8 isolate, the libuv event loop, CLI options, async hooks state, the inspector agent, and every per-execution-context value Node.js needs. We'll examine it in depth in Article 3.

When deserializing from a snapshot, the Environment and its PrincipalRealm are reconstituted from serialized data. When running without a snapshot, CreateMainEnvironment() creates a fresh V8 context and Environment, which triggers the full JavaScript bootstrap chain.

The JavaScript Bootstrap Chain

The bootstrap runs in a specific order, orchestrated by Realm::RunBootstrapping() and PrincipalRealm::BootstrapRealm():

sequenceDiagram
    participant REALM as Realm::RunBootstrapping
    participant BOOT as BootstrapRealm()
    
    Note over REALM: Step 1 — already run by V8 context setup
    REALM->>REALM: primordials.js<br/>Freeze copies of JS builtins
    
    Note over REALM: Step 2
    REALM->>REALM: ExecuteBootstrapper("internal/bootstrap/realm")<br/>Set up internalBinding, BuiltinModule
    
    Note over REALM: Step 3
    REALM->>BOOT: BootstrapRealm()
    BOOT->>BOOT: ExecuteBootstrapper("internal/bootstrap/node")<br/>Set up process object, globals
    
    Note over BOOT: Step 4 — Web APIs
    BOOT->>BOOT: "internal/bootstrap/web/exposed-wildcard"<br/>URL, EventTarget, AbortController, TextEncoder...
    BOOT->>BOOT: "internal/bootstrap/web/exposed-window-or-worker"<br/>fetch, WebSocket, crypto.subtle...
    
    Note over BOOT: Step 5 — Thread-specific
    BOOT->>BOOT: "internal/bootstrap/switches/is_main_thread"<br/>OR "is_not_main_thread"
    BOOT->>BOOT: "internal/bootstrap/switches/does_own_process_state"<br/>OR "does_not_own_process_state"

Each step serves a distinct purpose:

  • primordials.js captures frozen copies of JavaScript built-in methods (like Array.prototype.push) so internal modules aren't affected by user-land monkey-patching. This runs as part of the V8 context creation.

  • realm.js (source) sets up the three binding loaders (internalBinding, process.binding, process._linkedBinding) and the BuiltinModule system that internal modules use to require() each other.

  • node.js (source) configures the process object and global object properties.

  • The Web API scripts expose standard web platform APIs like URL, EventTarget, fetch, and TextEncoder on the global object.

  • The switch scripts configure thread-specific behavior (e.g., process.exit() only works on the main thread).

The StartExecution Dispatch Table

After bootstrap completes, LoadEnvironment() calls StartExecution(), which contains a dispatch table that selects which "main" script to run based on CLI flags and execution context:

flowchart TD
    SE["StartExecution()"] --> SNAP{"Snapshot deserialize<br/>main exists?"}
    SNAP -->|Yes| SNAP_MAIN["RunSnapshotDeserializeMain()"]
    SNAP -->|No| WORKER{"Worker thread?"}
    WORKER -->|Yes| W["internal/main/worker_thread"]
    WORKER -->|No| INSPECT{"argv[1] == 'inspect'?"}
    INSPECT -->|Yes| I["internal/main/inspect"]
    INSPECT -->|No| HELP{"--help?"}
    HELP -->|Yes| H["internal/main/print_help"]
    HELP -->|No| EVAL{"--eval?"}
    EVAL -->|Yes| E["internal/main/eval_string"]
    EVAL -->|No| SYNTAX{"--check?"}
    SYNTAX -->|Yes| SC["internal/main/check_syntax"]
    SYNTAX -->|No| TEST{"--test?"}
    TEST -->|Yes| T["internal/main/test_runner"]
    TEST -->|No| WATCH{"--watch?"}
    WATCH -->|Yes| WM["internal/main/watch_mode"]
    WATCH -->|No| HAS_FILE{"Has file argument?"}
    HAS_FILE -->|Yes| RUN["internal/main/run_main_module"]
    HAS_FILE -->|No| TTY{"stdin is TTY?"}
    TTY -->|Yes| REPL["internal/main/repl"]
    TTY -->|No| STDIN["internal/main/eval_stdin"]

This dispatch is visible in src/node.cc lines 283–401. The most common path — node script.js — lands on internal/main/run_main_module, which calls prepareMainThreadExecution(), marks bootstrap as complete, then invokes Module.runMain() to load the user's script.

The design is elegant: each entry mode is a self-contained JavaScript module in lib/internal/main/. This makes it straightforward to add new modes — when the test runner was added, it was simply a new case in the dispatch table pointing to internal/main/test_runner.

The Event Loop: SpinEventLoopInternal

After StartExecution() returns, control passes to SpinEventLoopInternal(), which is the heartbeat of every Node.js process:

flowchart TD
    START["SpinEventLoopInternal()"] --> MARK_START["Mark LOOP_START milestone"]
    MARK_START --> LOOP_TOP["Loop iteration"]
    LOOP_TOP --> STOP1{"env->is_stopping()?"}
    STOP1 -->|Yes| EXIT["Break"]
    STOP1 -->|No| UV["uv_run(UV_RUN_DEFAULT)<br/>Process I/O, timers, etc."]
    UV --> STOP2{"env->is_stopping()?"}
    STOP2 -->|Yes| EXIT
    STOP2 -->|No| DRAIN["platform->DrainTasks(isolate)<br/>Process V8 platform tasks"]
    DRAIN --> ALIVE{"uv_loop_alive()?"}
    ALIVE -->|Yes| LOOP_TOP
    ALIVE -->|No| BEFORE_EXIT["EmitProcessBeforeExit()"]
    BEFORE_EXIT --> SNAP_CB["RunSnapshotSerializeCallback()"]
    SNAP_CB --> ALIVE2{"uv_loop_alive() again?"}
    ALIVE2 -->|Yes| LOOP_TOP
    ALIVE2 -->|No| EXIT
    EXIT --> MARK_END["Mark LOOP_EXIT milestone"]
    MARK_END --> EMIT_EXIT["EmitProcessExitInternal()"]

The core loop in lines 36–63 is deceptively simple: call uv_run() to process libuv events, then DrainTasks() for V8 platform tasks (like garbage collection callbacks and WebAssembly compilation). If the loop has no more work, emit beforeExit — which gives user code one last chance to schedule work. If beforeExit handlers schedule new I/O, the loop continues. Otherwise, EmitProcessExitInternal() fires the exit event and the process terminates.

Tip: The beforeExit event is the mechanism that keeps await at the top level working. When a top-level await resolves and schedules a microtask, uv_loop_alive() returns false (no I/O pending), but the beforeExit handler can check for pending promises and keep the loop spinning.

What's Next

We've traced the path from the operating system's process creation through to a running event loop. But we skipped over a crucial question: how do JavaScript objects communicate with C++ objects? In the next article, we'll dive into the C++↔JavaScript bridge — the BaseObject class hierarchy, the Environment god object, and the binding system that connects the two worlds.