From main() to Your First Line of JavaScript
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:
- Parses debug environment variables
- Runs platform initialization (
PlatformInit) - Parses CLI arguments and environment variables (including
NODE_OPTIONS) - Optionally maps static code to large pages for performance
- Handles early-exit flags (
--version,--help,--v8-options) - Initializes OpenSSL (if compiled in)
- Initializes ICU for internationalization
- 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.hwhere the option is defined, then trace intoInitializeNodeWithArgsInternal()insidenode.ccto 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.jscaptures frozen copies of JavaScript built-in methods (likeArray.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 theBuiltinModulesystem that internal modules use torequire()each other. -
node.js(source) configures theprocessobject and global object properties. -
The Web API scripts expose standard web platform APIs like
URL,EventTarget,fetch, andTextEncoderon 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
beforeExitevent is the mechanism that keepsawaitat the top level working. When a top-levelawaitresolves and schedules a microtask,uv_loop_alive()returns false (no I/O pending), but thebeforeExithandler 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.