Electron embeds both Node.js and Chromium into a single runtime. Because Chromium is multi-process by design, every Electron app is also multi-process. Two process types dominate the model: the main process and one or more renderer processes.
There is exactly one main process per Electron app. It is the entry point — the file specified under "main" in package.json. It runs in a full Node.js environment with unrestricted access to the OS.
BrowserWindow instancesapp.whenReady(), app.quit(), activation eventsipcMainconst { app, BrowserWindow, ipcMain } = require('electron') const path = require('node:path') app.whenReady().then(() => { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), sandbox: true, // renderer is sandboxed nodeIntegration: false, // never expose Node to renderer contextIsolation: true, // isolate preload world from page world }, }) win.loadFile('index.html') }) // handle IPC from renderer ipcMain.handle('read-config', async () => { return fs.readFileSync('config.json', 'utf8') })
nodeIntegration: true in a renderer that loads remote content. A compromised renderer with Node access can execute arbitrary code on the host.
Each BrowserWindow (and each <webview>) runs its own renderer process — an isolated Chromium render process. By default it is sandboxed: no Node.js, no direct OS access, identical security model to a Chrome tab.
contextBridge API surfacefetch / WebSockets for network (subject to CSP)require / Node.js built-ins// window.electronAPI is injected by the preload script const config = await window.electronAPI.readConfig() document.getElementById('output').textContent = config
Preload scripts are the controlled bridge between the two worlds. They execute in the renderer process before the page loads, but in a separate JavaScript context — they have access to Node.js and Electron APIs, while the page does not. With contextIsolation: true, the preload world and the page world are completely isolated; you must explicitly expose what you want available to the page using contextBridge.exposeInMainWorld.
const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('electronAPI', { // expose only specific, named operations — never expose ipcRenderer itself readConfig: () => ipcRenderer.invoke('read-config'), onUpdate: (callback) => ipcRenderer.on('update-available', (_event, data) => callback(data)), })
contextBridge.exposeInMainWorld('ipc', ipcRenderer) — this exposes the entire IPC object to the page, allowing it to send arbitrary channel messages.
Main and renderer processes have separate memory spaces and cannot share objects directly. All communication goes through Electron's IPC layer, which serializes messages using the Structured Clone Algorithm — the same algorithm used by postMessage. Functions, prototypes, and non-serializable objects are stripped.
ipcRenderer.invoke returns a Promise that resolves when ipcMain.handle completes. This is the recommended pattern for most operations.
const result = await window.electronAPI.readConfig() // resolves when ipcMain.handle returns
ipcMain.handle('read-config', async (event) => { // event.sender is the WebContents of the calling renderer return await fs.promises.readFile('config.json', 'utf8') })
Use when no response is needed, or when main pushes events to a renderer unprompted.
// push to a specific renderer window win.webContents.send('update-available', { version: '2.1.0' })
ipcRenderer.on('update-available', (_event, data) => callback(data))
'app:read-config', 'fs:write-file'. Validate all incoming data in ipcMain.handle — treat renderer input as untrusted.
| Property | Main Process | Renderer Process |
|---|---|---|
| Count | Exactly 1 | 1 per BrowserWindow / webview |
| Runtime | Node.js + Electron | Chromium (sandboxed) |
| DOM access | None | Full |
| Node APIs | Full (fs, net, child_process…) | None by default |
| Electron APIs | app, BrowserWindow, ipcMain, Menu, Tray, dialog, shell… | ipcRenderer, contextBridge (preload only) |
| Entry point | main field in package.json |
HTML file loaded by BrowserWindow |
| Crashes | Kills the app | Isolated; other windows survive |
| Debugging | Node.js inspector (--inspect) |
Chrome DevTools (F12) |
Since Electron 21, a third process type exists: the UtilityProcess. It is a Node.js child process with access to Node APIs but no Chromium renderer context — useful for CPU-intensive work, native modules, or crash-isolated background tasks that should not block the main process.
const { utilityProcess } = require('electron') const child = utilityProcess.fork(path.join(__dirname, 'worker.js')) child.on('message', (msg) => console.log('from worker:', msg)) child.postMessage({ task: 'parse', payload: largeBuffer })
UtilityProcess communicates with main via postMessage/on('message'), not IPC channels. It cannot communicate with renderers directly.
With contextIsolation: true (default since Electron 12), the preload script and the page share the same DOM but run in separate V8 contexts. Objects created in one context are not directly accessible from the other. contextBridge creates a deep-cloned, read-only proxy of whatever you expose.
contextBridge. Only plain objects, arrays, primitives, and functions (converted to bound IPC calls) survive the bridge.
// ✓ works contextBridge.exposeInMainWorld('api', { getValue: () => ipcRenderer.invoke('get-value'), config: { theme: 'dark', version: 3 }, }) // ✗ class instances don't cross the bridge contextBridge.exposeInMainWorld('api', new MyService()) // methods lost
Understanding when processes start and end matters for resource management and crash handling.
app.quit() or closing all windows (on non-macOS) ends it.new BrowserWindow() or new BrowserView(). It is destroyed when the window closes or win.destroy() is called.render-process-gone on the WebContents object. The main process can respond by reloading the window without restarting the whole app.win.webContents.on('render-process-gone', (event, details) => { console.error('renderer crashed:', details.reason) win.reload() })