Electron.j.s Process Model: Main vs Renderer

Overview

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.

main process
Node.js (full)
+
Electron APIs

renderer process 1
Chromium (sandboxed)
renderer process 2
Chromium (sandboxed)
renderer process N
Chromium (sandboxed)

↕ IPC (ipcMain / ipcRenderer) ↕

Main Process

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.

Responsibilities

main.js — minimal main process
const { 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')
})
Security Never set nodeIntegration: true in a renderer that loads remote content. A compromised renderer with Node access can execute arbitrary code on the host.

Renderer Process

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.

What renderers can do

What renderers cannot do (by default)

renderer.js — calls exposed API, no Node access
// window.electronAPI is injected by the preload script
const config = await window.electronAPI.readConfig()
document.getElementById('output').textContent = config

Preload Scripts

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.

preload.js
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)),
})
Do not do this contextBridge.exposeInMainWorld('ipc', ipcRenderer) — this exposes the entire IPC object to the page, allowing it to send arbitrary channel messages.

IPC: Inter-Process Communication

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.

renderer
ipcRenderer.invoke / send
main
ipcMain.handle / send
renderer

invoke / handle — request-response (preferred)

ipcRenderer.invoke returns a Promise that resolves when ipcMain.handle completes. This is the recommended pattern for most operations.

renderer
const result = await window.electronAPI.readConfig()
// resolves when ipcMain.handle returns
main
ipcMain.handle('read-config', async (event) => {
  // event.sender is the WebContents of the calling renderer
  return await fs.promises.readFile('config.json', 'utf8')
})

send / on — fire-and-forget

Use when no response is needed, or when main pushes events to a renderer unprompted.

main → renderer (push notification)
// push to a specific renderer window
win.webContents.send('update-available', { version: '2.1.0' })
preload (listener)
ipcRenderer.on('update-available', (_event, data) => callback(data))
Channel naming Prefix channels with a namespace to avoid collisions: 'app:read-config', 'fs:write-file'. Validate all incoming data in ipcMain.handle — treat renderer input as untrusted.

Main vs Renderer — Quick Reference

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)

Utility Process

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.

main.js — spawning a utility 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.

Context Isolation Details

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.

Implication You cannot pass a class instance with methods through contextBridge. Only plain objects, arrays, primitives, and functions (converted to bound IPC calls) survive the bridge.
What survives contextBridge
// ✓ 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

Process Lifecycle

Understanding when processes start and end matters for resource management and crash handling.

main.js — handle renderer crash
win.webContents.on('render-process-gone', (event, details) => {
  console.error('renderer crashed:', details.reason)
  win.reload()
})