Steven's Knowledge

Process Model

How Electron's processes fit together — the main process, renderer processes, the preload bridge, contextIsolation, IPC patterns, MessagePorts, and utilityProcess

Process Model

Electron is a multi-process architecture borrowed from Chromium and fused with Node.js. Understanding which code runs where — and how the pieces talk — is the foundation for everything else: security, performance, and correctness.

The processes at a glance

                       ┌──────────────────────────────┐
                       │         Main process          │
                       │  Node.js + Electron APIs      │
                       │  app lifecycle, windows,      │
                       │  native dialogs, fs, OS       │
                       └───────────────┬───────────────┘
                          IPC (async)  │  webContents.send (push)
              ┌────────────────────────┼────────────────────────┐
              │                        │                         │
   ┌──────────┴──────────┐  ┌──────────┴──────────┐   ┌──────────┴──────────┐
   │   Preload (win A)    │  │   Preload (win B)    │   │   utilityProcess    │
   │  isolated world,     │  │  isolated world,     │   │  sandboxed Node     │
   │  contextBridge       │  │  contextBridge       │   │  child (CPU/IO)     │
   ├──────────────────────┤  ├──────────────────────┤   └─────────────────────┘
   │   Renderer (win A)    │  │   Renderer (win B)   │
   │  Chromium, your UI,  │  │  Chromium, your UI,  │
   │  NO Node             │  │  NO Node             │
   └──────────────────────┘  └──────────────────────┘

The main process

There is exactly one main process. It is the app's entry point, runs in a full Node.js environment, and owns everything privileged:

  • App lifecycleapp.whenReady, window-all-closed, activate, quitting, single-instance lock.
  • Windows — creating and managing every BrowserWindow; only the main process can.
  • Native and OS — menus, tray, dialogs, notifications, the filesystem, spawning processes, registering protocols.

Because it drives the event loop that services every window, blocking it freezes the whole app. Treat the main process as the trusted core: it holds the privilege the renderers must not have.

Renderer processes

Each BrowserWindow (and each <webview>/BrowserView) gets its own renderer process — a Chromium process running your HTML, CSS, and JavaScript. With modern, secure defaults a renderer:

  • runs in the OS sandbox (sandbox: true),
  • has no Node.js (nodeIntegration: false) — no require, no process, no fs,
  • runs page scripts in a JavaScript context isolated from the preload (contextIsolation: true).

Treat the renderer as untrusted. It renders the UI and nothing more; any privileged action it needs must be requested from main over IPC, through the preload bridge.

The preload script

The preload runs before the page's own scripts load, in the renderer process but in a special environment. With sandbox: true it gets a limited Node subset (electron's renderer parts, plus events, timers, url) — not full Node. Its one job: build the bridge between the untrusted page and the privileged main process, and keep that bridge narrow.

// preload.ts
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('api', {
  readConfig: () => ipcRenderer.invoke('config:read'),
  onConfigChanged: (cb: (cfg: unknown) => void) => {
    const handler = (_e: unknown, cfg: unknown) => cb(cfg);
    ipcRenderer.on('config:changed', handler);
    return () => ipcRenderer.removeListener('config:changed', handler);
  },
});

exposeInMainWorld(key, value) copies value into the page's window[key] — but across the isolation boundary, so the page sees a frozen, structured clone of your functions, not your preload's live objects.

contextIsolation explained

Context isolation runs your preload and the loaded page in separate JavaScript worlds. They share the same DOM, but not the same JS globals, prototypes, or closures.

┌─────────────────── Renderer process ───────────────────┐
│                                                          │
│   Isolated world (preload)        Main world (page)      │
│   ┌───────────────────┐           ┌──────────────────┐   │
│   │ ipcRenderer        │           │ window.api  ◄────┼── only what contextBridge
│   │ contextBridge      │  ──────►  │ your app JS       │   exposed crosses over
│   │ (privileged refs)  │           │ (untrusted)       │   │
│   └───────────────────┘           └──────────────────┘   │
│            shared DOM ──────────────────────────          │
└──────────────────────────────────────────────────────────┘

Without isolation, page code could overwrite Array.prototype.map, monkey-patch JSON.parse, or reach into the preload's closures to steal the privileged ipcRenderer reference. Isolation makes the bridge the only channel between the two worlds — which is exactly why it must expose functions, not raw objects.

IPC patterns

Inter-process communication is how renderers ask main to do privileged work and how main pushes updates back. There are three shapes.

Request/response — invoke / handle (preferred)

For anything where the renderer wants an answer, use ipcRenderer.invoke (returns a Promise) against an ipcMain.handle (can be async, returns a value). One call, one typed result, errors propagate as rejections.

// preload.ts
contextBridge.exposeInMainWorld('api', {
  loadProfile: (id: string) => ipcRenderer.invoke('profile:load', id),
});

// main.ts
ipcMain.handle('profile:load', async (_event, id: string) => {
  return db.getProfile(id); // resolved value travels back to the renderer
});

Fire-and-forget — send / on

When the renderer just notifies main of something and expects no reply, use ipcRenderer.send with ipcMain.on. It is one-way.

ipcRenderer.send('analytics:event', { name: 'opened-settings' });
// main.ts
ipcMain.on('analytics:event', (_event, payload) => track(payload));

Main → renderer push — webContents.send

To push data into a renderer (a config change, a progress update), the main process calls webContents.send; the renderer listens with ipcRenderer.on (wired through the preload).

// main.ts — push to a specific window
win.webContents.send('config:changed', newConfig);

Comparison

MechanismDirectionReply?Use for
invoke / handlerenderer → mainyes (Promise)Request/response: read a file, query the DB, run an action
send / onrenderer → mainnoFire-and-forget notifications, analytics, commands
webContents.send / ipcRenderer.onmain → renderernoPush updates: progress, state changes, menu actions
MessageChannelMain / MessagePortany ↔ anyn/a (stream)Direct renderer↔renderer, or high-throughput streams

Avoid synchronous IPC. ipcRenderer.sendSync blocks the calling renderer until main replies, and the handler blocks main while it runs — freezing every window. Use the async invoke/handle pair for request-response; reserve sync IPC for trivial startup reads, if ever.

MessagePorts for direct and high-throughput channels

Routing every message through the main process adds a hop. For two renderers that need to talk directly, or for a high-volume stream, MessageChannelMain creates a pair of MessagePorts you hand out — after which the data flows between the endpoints without touching the main process event loop.

// main.ts — create the channel, give one port to each renderer
import { MessageChannelMain } from 'electron';

const { port1, port2 } = new MessageChannelMain();
windowA.webContents.postMessage('port', null, [port1]);
windowB.webContents.postMessage('port', null, [port2]);

// preload.ts (each renderer) — receive the port and start messaging
ipcRenderer.on('port', (event) => {
  const [port] = event.ports;
  port.onmessage = (e) => handle(e.data);
  port.start();
  port.postMessage('hello from the other side');
});

Use MessagePorts when a hub-and-spoke through main would be a bottleneck — large binary transfers, audio/video frames, or chatty renderer-to-renderer protocols.

utilityProcess for CPU/IO offload

When you need to run Node code off the main process — parsing a huge file, hashing, image work, a long-lived background service — use utilityProcess.fork, Electron's purpose-built API for child processes.

// main.ts
import { utilityProcess } from 'electron';
import path from 'node:path';

const child = utilityProcess.fork(path.join(__dirname, 'workers/parse.js'));
child.postMessage({ file: '/path/to/big.json' });
child.on('message', (result) => log.info('parsed', result));

Why utilityProcess over plain child_process.fork:

  • It runs in Electron's process model, so it integrates with Electron's lifecycle, crash reporting, and logging.
  • It communicates over the same structured postMessage / MessagePort machinery, including transferable ports.
  • It can be sandboxed and runs with the correct Electron Node runtime — no ABI mismatch with native modules you load there.

Keep CPU- or IO-bound work here so the main process stays responsive for the UI.

End-to-end typed example

Tying it together: the preload exposes api.readConfig(), the main process handles it, the renderer calls it — all typed through a shared interface.

// shared/api.ts
export interface AppConfig { theme: 'light' | 'dark' }
export interface AppApi { readConfig(): Promise<AppConfig> }
declare global {
  interface Window { api: AppApi }
}
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { AppApi } from './shared/api';

const api: AppApi = {
  readConfig: () => ipcRenderer.invoke('config:read'),
};
contextBridge.exposeInMainWorld('api', api);
// main.ts
import { ipcMain } from 'electron';
import type { AppConfig } from './shared/api';
import { loadConfig } from './services/config';

ipcMain.handle('config:read', async (): Promise<AppConfig> => {
  return loadConfig(); // reads from disk in the privileged main process
});
// renderer (your UI) — fully typed, no Node, no direct fs access
const config = await window.api.readConfig();
document.body.dataset.theme = config.theme;

The renderer never touches the filesystem; it asks window.api, which forwards an async IPC request to main, which does the privileged read and returns a typed result. That round-trip — untrusted renderer → narrow preload bridge → trusted main — is the spine of every well-built Electron app.

Pick the mechanism by shape, not habit. Request/response → invoke/handle. Notification → send/on. Main pushing to a window → webContents.send. Direct or high-throughput → MessagePort. Heavy Node work → utilityProcess. Matching the tool to the message shape keeps the main process unblocked and the boundaries clean.

On this page