Using Electron well means understanding its process model, how processes talk to each other over IPC, and the security model built on top of both. This chapter covers BrowserWindow, ipcMain and ipcRenderer, contextBridge, context isolation, and the security checklist.
The process model
Electron inherits Chromium's multi-process architecture.
- Main process: exactly one per app, running in a Node.js environment. It owns the app lifecycle (
app), windows (BrowserWindow), menus, the tray, dialogs, and other native features. - Renderer processes: one per window (strictly, per
webContents), each running a web page. By default they have no Node.js access. - Utility processes: Node.js child processes started from the main process with
utilityProcess.fork(). Use them to move heavy computation or crash-prone work out of the main process.
If the main process is blocked by a long task, every window appears frozen. Move heavy work to asynchronous APIs or a utility process.
// main.js
const { utilityProcess } = require('electron')
const path = require('node:path')
const child = utilityProcess.fork(path.join(__dirname, 'worker.js'))
child.on('message', (result) => console.log('result:', result))
child.postMessage({ numbers: [1, 2, 3] })
// worker.js
process.parentPort.on('message', (event) => {
const sum = event.data.numbers.reduce((a, b) => a + b, 0)
process.parentPort.postMessage(sum)
})
BrowserWindow
A BrowserWindow creates one native window and the renderer inside it. Alongside window options such as size, frame, and minimum size, webPreferences sets the renderer's security options.
const win = new BrowserWindow({
width: 1000,
height: 700,
minWidth: 600,
show: false, // show once content is ready to avoid a flash
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // default
nodeIntegration: false, // default
sandbox: true, // default
},
})
win.once('ready-to-show', () => win.show())
win.loadFile('index.html')
contextIsolation, nodeIntegration: false, and sandbox are already the defaults in current Electron. Spelling them out documents the intent and makes an accidental change easy to spot in review.
IPC: talking between processes
Renderers cannot reach the operating system directly, so they ask the main process over IPC. The most common pattern is request and response: register a handler in the main process with ipcMain.handle, call it from the renderer side (the preload) with ipcRenderer.invoke, and receive the result as a Promise.
// main.js
const { ipcMain, dialog } = require('electron')
ipcMain.handle('dialog:openFile', async (event) => {
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] })
return canceled ? null : filePaths[0]
})
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
setTitle: (title) => ipcRenderer.send('set-title', title),
onUpdateCounter: (callback) => {
ipcRenderer.on('update-counter', (_event, value) => callback(value))
},
})
Fire-and-forget messages use ipcRenderer.send with ipcMain.on. When the main process needs to notify a renderer first, it calls win.webContents.send, and the preload listens with ipcRenderer.on and forwards the value to a callback.
// main.js
ipcMain.on('set-title', (event, title) => {
const win = BrowserWindow.fromWebContents(event.sender)
win.setTitle(title)
})
win.webContents.send('update-counter', 1)
// renderer.js
document.getElementById('open').addEventListener('click', async () => {
const filePath = await window.api.openFile()
if (filePath) document.getElementById('file').textContent = filePath
})
window.api.onUpdateCounter((value) => console.log('counter:', value))
contextBridge and context isolation
With context isolation enabled, the preload script and the web page run in separate JavaScript worlds. Scripts on the page cannot tamper with the preload's variables or with Electron APIs. The only passage between the two worlds is contextBridge.exposeInMainWorld; only the functions and values passed through it are visible to the page.
Exposing the entire ipcRenderer object would let the page send messages on any channel and defeat the isolation. Expose narrow, purpose-built functions as in the example above, and do not hand the raw event object to page callbacks.
The security checklist
The heart of Electron's official security checklist:
- Do not enable
nodeIntegration in renderers. - Do not disable
contextIsolation or sandbox. - Load remote content only over HTTPS, and never give untrusted content access to preload features.
- Set a Content Security Policy on every page.
- Validate the sender (
event.senderFrame) and the arguments of every IPC message. - Restrict new windows and navigation to unexpected destinations.
- Pass only validated URLs to
shell.openExternal. - Keep Electron on a currently supported version.
win.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('https://example.com/')) shell.openExternal(url)
return { action: 'deny' }
})
win.webContents.on('will-navigate', (event, url) => {
if (!url.startsWith('file://')) event.preventDefault()
})
Summary
- There is one main process and one renderer per window; heavy work can move to a utility process.
- Use
ipcMain.handle with ipcRenderer.invoke for request and response, send with on for one-way messages, and webContents.send from main to renderer. - Under context isolation, expose a narrow API through
contextBridge. - Follow the security checklist: keep Node.js out of renderers, and apply a CSP, IPC validation, and navigation limits.
0 comments
Sign in · Sign in to leave a comment.