Read OSS

From main.ts to First Paint: VS Code's Multi-Process Startup

Advanced

Prerequisites

  • Article 1: Architecture and Layering
  • Basic understanding of Electron's multi-process model (main vs renderer)

From main.ts to First Paint: VS Code's Multi-Process Startup

Every time you launch Visual Studio Code, a carefully orchestrated startup sequence crosses at least three OS processes and initializes hundreds of services — yet the editor appears in under a second on modern hardware. This article traces that sequence from the very first line of src/main.ts through to the rendered workbench shell, explaining how Visual Studio Code balances correctness with startup speed.

The Electron Main Process Boot

Everything begins at src/main.ts, the Electron main process entry point. Before Electron's app.ready event even fires, this file performs critical synchronous setup:

  1. Performance marksperf.mark('code/didStartMain') starts the clock immediately.
  2. Portable modeconfigurePortable(product) checks for a portable installation.
  3. CLI argument parsingparseCLIArgs() uses minimist for early flag detection.
  4. Sandbox configuration — Enables Chromium's process sandbox unless explicitly disabled.
  5. User data pathgetUserDataPath() resolves the profile directory before app.ready.
  6. Protocol registrationvscode-webview:// and vscode-file:// custom schemes are registered with appropriate security privileges.
  7. Crash reporter — Configured synchronously so crashes during startup are captured.
sequenceDiagram
    participant OS as Operating System
    participant Main as src/main.ts
    participant Electron as Electron Runtime
    participant CodeMain as CodeMain

    OS->>Main: Launch process
    Main->>Main: Parse CLI args
    Main->>Main: Configure sandbox
    Main->>Main: Set userData path
    Main->>Main: Register protocols
    Main->>Main: Configure crash reporter
    Main->>Electron: Register app.ready listener
    Electron->>Main: app.ready event
    Main->>Main: Resolve NLS configuration
    Main->>Main: bootstrapESM()
    Main->>CodeMain: import('./vs/code/electron-main/main.js')

The key insight is that src/main.ts does as much synchronous work as possible before app.ready, because Electron blocks on this event. Once ready, the onReady() function resolves NLS (localization) configuration in parallel with creating the code cache directory, then calls startup() which bootstraps ESM and dynamically imports the real entry point: src/vs/code/electron-main/main.ts.

CodeMain: Single-Instance Lock and Initial Services

The CodeMain class is where the main process really takes shape. Its startup() method follows a deliberate sequence:

src/vs/code/electron-main/main.ts#L97-L160

Step 1: Create initial services. createServices() (line 162) builds the bootstrap service collection — Product, Environment, Logger, Log, Files, State, UserDataProfiles, Policy, Configuration, Lifecycle, and a few others. These are the minimum set needed to determine if we're the primary instance.

Step 2: Initialize services. Directories are created (mkdir -p style), state is loaded, configuration is read from disk.

Step 3: Claim the single-instance lock. claimInstance() attempts to start an IPC server on a named pipe. If it succeeds, we're the primary instance. If it fails with EADDRINUSE, another instance is running — we connect to it as a client, forward our CLI arguments, and exit.

flowchart TD
    A["CodeMain.startup()"] --> B["createServices()<br/>Bootstrap ~15 services"]
    B --> C["initServices()<br/>mkdir, load state, init config"]
    C --> D["claimInstance()<br/>Try IPC server bind"]
    D -->|Success: first instance| E["Create CodeApplication"]
    D -->|EADDRINUSE| F["Connect as IPC client"]
    F --> G["Forward args to primary instance"]
    G --> H["Exit"]
    E --> I["CodeApplication.startup()"]

This single-instance pattern is critical. Without it, two VS Code instances would fight over state files, extension hosts, and user data. The IPC forwarding means that opening a file from the terminal always routes to the existing window.

Tip: If VS Code won't start and you see "Another instance is running" errors, the stale IPC pipe is usually in $XDG_RUNTIME_DIR on Linux or ~/Library/Application Support/ on macOS. Deleting the socket file resolves it.

CodeApplication: The Main Process Singleton

Once we've claimed the instance lock, CodeApplication takes over. This is the main process singleton — created via DI with about a dozen injected services:

src/vs/code/electron-main/app.ts#L159-L172

The constructor immediately configures Electron session security (CSP, permission handlers, certificate validation) and registers IPC listeners. Then startup() builds the full main-process service graph:

src/vs/code/electron-main/app.ts#L552-L604

The startup() method:

  1. Sets the Win32 app user model ID (for taskbar grouping)
  2. Creates the Electron IPC server for renderer↔main communication
  3. Resolves machine telemetry IDs
  4. Sets up the Shared Process — a hidden Electron renderer that hosts services shared across windows (extension management, telemetry batching, search indexing)
  5. Calls initServices() to create the expanded service graph (telemetry, storage, workspaces, windows management, update service, terminal pty host, and many more)
  6. Opens or restores windows via WindowsMainService

Renderer-Side Bootstrap: DesktopMain and Workbench

Each VS Code window runs in an Electron renderer process. The bootstrap happens in DesktopMain:

sequenceDiagram
    participant Main as Main Process
    participant Renderer as Renderer Process
    participant DM as DesktopMain
    participant WB as Workbench
    
    Main->>Renderer: Create BrowserWindow
    Renderer->>DM: new DesktopMain(config)
    DM->>DM: reviveUris()
    DM->>DM: initServices() in parallel with DOM ready
    DM->>DM: Create ServiceCollection
    Note over DM: MainProcessService, Product, Environment,<br/>Logger, Policy, SharedProcess, Files,<br/>Remote, Storage, Configuration...
    DM->>WB: new Workbench(document.body, services)
    DM->>WB: workbench.startup()
    WB->>WB: initServices() — collect all registerSingleton() descriptors
    WB->>WB: initLayout()
    WB->>WB: Registry.start() — workbench contributions
    WB->>WB: renderWorkbench() — create UI parts
    WB->>WB: restore() — restore editors, views

The open() method at desktop.main.ts#L114-L142 parallelizes service initialization with DOM readiness — a smart optimization since both are I/O-bound. It explicitly applies the zoom level before creating the Workbench to prevent visual flicker.

Notice the prominent comments in initServices(): "NOTE: Please do NOT register services here. Use registerSingleton() from workbench.common.main.ts". This is the barrel file system from Article 1 in action. DesktopMain only registers services that need direct access to the main process IPC channel. Everything else comes from the globally-registered singletons.

The Process Zoo: Six Roles Explained

VS Code isn't just "main + renderer." There are six distinct process roles:

graph TD
    MAIN["<b>Main Process</b><br/>Electron main<br/>Window management, OS integration,<br/>lifecycle, IPC hub"]
    
    RENDERER["<b>Renderer Process</b><br/>Electron renderer (per window)<br/>Workbench UI, editor, panels"]
    
    SHARED["<b>Shared Process</b><br/>Hidden Electron renderer<br/>Extension management, search indexing,<br/>telemetry batching"]
    
    UTILITY["<b>Utility Processes</b><br/>Node.js child processes<br/>PTY host (terminal), file watcher"]
    
    EXTHOST["<b>Extension Host</b><br/>Node.js / Web Worker<br/>Runs extension code in isolation"]
    
    SERVER["<b>Remote Server</b><br/>Headless Node.js<br/>SSH/container/WSL backend"]
    
    MAIN --> RENDERER
    MAIN --> SHARED
    MAIN --> UTILITY
    RENDERER --> EXTHOST
    SERVER --> EXTHOST
    
    style MAIN fill:#e3f2fd
    style RENDERER fill:#e8f5e9
    style SHARED fill:#fff3e0
    style UTILITY fill:#f3e5f5
    style EXTHOST fill:#fce4ec
    style SERVER fill:#e0f2f1
  • Main process — The Electron "brain." Manages windows, handles OS-level integration (menus, dock, file associations), coordinates IPC between all other processes.
  • Renderer process — One per VS Code window. Runs the full workbench UI. Sandboxed with no direct Node.js access (communicates with main via IPC).
  • Shared process — A headless renderer that caches and shares work across windows: extension gallery queries, telemetry aggregation, settings sync.
  • Utility processes — Electron's UtilityProcess API for CPU/IO-intensive work. The PTY host (terminal backend) is the primary example — it manages all pseudoterminal instances.
  • Extension host — Runs third-party extension code. Isolated from the renderer. Can be a local Node.js process, a web worker, or a remote process on another machine.
  • Remote server — The headless backend for remote development (SSH, containers, WSL). Hosts extension hosts and file system access on the remote machine.

Alternative Entries: CLI and Remote Server

Not every VS Code startup involves Electron. The CLI entry at src/cli.ts is a lightweight 26-line script. It resolves NLS, bootstraps ESM, sets VSCODE_CLI=1, and imports the CLI handler — used for operations like code --install-extension and code --diff.

The remote server at src/server-main.ts is more substantial. It parses server-specific CLI args, optionally prompts for license acceptance, creates an HTTP server, and lazily loads the remote extension host agent. The key design decision: the HTTP server starts listening before the full VS Code server module loads. This means the port is claimed immediately, and the first actual request triggers the expensive initialization:

flowchart LR
    A["server-main.ts"] --> B["Parse args"]
    B --> C["Create HTTP server"]
    C --> D["server.listen()"]
    D --> E["First request arrives"]
    E --> F["Lazy load server.main.js"]
    F --> G["Create extension host agent"]

This lazy loading pattern means the server appears responsive to connection tools (like the Remote-SSH extension) even while it's still loading hundreds of services in the background.

What's Next

We've seen what gets instantiated during startup, but we glossed over how hundreds of services get wired together without manual constructor calls. The next article dives into VS Code's custom dependency injection system — the createDecorator/InstantiationService pattern that makes all of this service graph construction possible.