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 lifecycle —
app.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) — norequire, noprocess, nofs, - 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
| Mechanism | Direction | Reply? | Use for |
|---|---|---|---|
invoke / handle | renderer → main | yes (Promise) | Request/response: read a file, query the DB, run an action |
send / on | renderer → main | no | Fire-and-forget notifications, analytics, commands |
webContents.send / ipcRenderer.on | main → renderer | no | Push updates: progress, state changes, menu actions |
MessageChannelMain / MessagePort | any ↔ any | n/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/MessagePortmachinery, 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.