The One Electron Bridge You Actually Need: contextBridge Without the Headaches
Electron's context isolation wall is deliberate — and contextBridge is the only door through it. A practical walkthrough of the preload/main/renderer pattern, Structured Clone pitfalls, and the escape hatches to avoid.
16 Sept 2026, 05:35 UTC

If you've ever typed window.api.saveFile() in an Electron renderer and gotten undefined — or worse, typed require('fs') in the DevTools console and watched it work — you've hit the boundary Electron draws on purpose. Since Electron 12, the renderer process and your Node.js-powered preload script run in separate V8 contexts. That separation is the whole security model, and the only sanctioned way across it is contextBridge. This post is about designing that bridge well, because most bugs and most vulnerabilities in Electron apps live right at this seam.
Why the wall exists
A renderer loads web content. Web content gets XSS'd. If that content can reach require, process, or Buffer, a script-injection bug becomes remote code execution on the user's machine. Context isolation makes the page's JavaScript run in a world where none of those globals exist, while your preload script — which runs before the page loads, with limited Node access — lives in a different world entirely.
You can verify the wall is up in about ten seconds. Open the renderer's DevTools and check:
typeof require // \"undefined\" — good\ntypeof process // \"undefined\" — good\ntypeof window.api // \"object\" — your bridge, if configuredIf require resolves to \"function\", something in your webPreferences has opted out of the defaults, and you should treat that as a bug, not a convenience.
The bridge pattern, end to end
The supported architecture has three pieces: a preload script that exposes a narrow API, a main-process handler that does the privileged work, and a renderer that calls the API like any async function. Here's a minimal but realistic version, assuming Electron 28+ defaults (contextIsolation: true, sandbox: true).
preload.ts — the only file that touches both worlds:
import { contextBridge, ipcRenderer } from 'electron';\n\ncontextBridge.exposeInMainWorld('api', {\n readConfig: () => ipcRenderer.invoke('config:read'),\n writeConfig: (data: unknown) => ipcRenderer.invoke('config:write'),\n});main.ts — where the privilege lives:
import { app, BrowserWindow, ipcMain } from 'electron';\nimport { readFile, writeFile } from 'node:fs/promises';\nimport path from 'node:path';\n\nconst configPath = () => path.join(app.getPath('userData'), 'config.json');\n\nipcMain.handle('config:read', async () => {\n try {\n return JSON.parse(await readFile(configPath(), 'utf8'));\n } catch {\n return {}; // missing file is not an error for the renderer\n }\n});\n\nipcMain.handle('config:write', async (_event, data) => {\n // Validate here — the renderer is untrusted input.\n if (typeof data !== 'object' || data === null) throw new Error('bad config');\n await writeFile(configPath(), JSON.stringify(data, null, 2));\n});renderer — no Electron imports at all:
const cfg = await window.api.readConfig();\nawait window.api.writeConfig({ ...cfg, theme: 'dark' });Notice what the renderer never sees: ipcRenderer itself, channel names as raw strings it can invent, file paths, or any Node global. The preload is an allowlist, not a passthrough. If you find yourself exposing invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), you've rebuilt nodeIntegration with extra steps — any injected script can now call any channel.
Structured Clone will bite you exactly once
Everything crossing the bridge — arguments and return values — is copied with the Structured Clone Algorithm. Primitives, plain objects, arrays, TypedArrays, and ArrayBuffers survive. Functions, Promises, class instances, DOM nodes, and cyclic objects do not. The failure mode is a runtime DataCloneError, often deep in a call stack that doesn't obviously mention the bridge.
The practical rules:
- Return plain data from handlers. If your main-process code produces a class instance (a database row object, a
Date-heavy model), serialize it to a plain object first.Dateactually survives structured clone, but custom prototypes don't. - Never try to pass a callback across the bridge. For events (progress, streaming), expose a subscription:
onProgress: (cb) => { const l = (_e, p) => cb(p); ipcRenderer.on('progress', l); return () => ipcRenderer.removeListener('progress', l); }. The callback stays in the renderer; only the cloned payload crosses. - Async works because
ipcRenderer.invokereturns a Promise inside the preload world, and the bridge resolves it to cloned data on the renderer side. You don't pass Promises through; you await them.
One debugging quirk worth knowing before it costs you an hour: console.log in a preload script does not appear in the renderer's DevTools console. It goes to the terminal that launched the app (run with ELECTRON_ENABLE_LOGGING=1 to be sure) or to a DevTools instance attached to the preload context. If your preload \"isn't running,\" check the terminal before assuming a load failure.
Trade-offs and the escape hatches you shouldn't take
The honest cost of this architecture is friction. Every new capability is three edits (preload, main handler, renderer call) plus a type declaration. For TypeScript, keep a preload.d.ts next to the renderer code:
export interface Api {\n readConfig: () => Promise<Record<string, unknown>>;\n writeConfig: (data: unknown) => Promise<void>;\n}\ndeclare global { interface Window { api: Api } }Two temptations to resist. First, contextIsolation: false or nodeIntegration: true: these re-enable require in the renderer and dismantle the XSS-to-RCE defense. They exist for legacy migration, not new code. Second, moving heavy work into the main process because the bridge is annoying — that blocks the event loop that serves every window. If you have genuinely long-running Node work (parsing large files, native addons), Electron 22+ Utility Processes give you a separate Node environment that talks over MessagePorts, keeping the main process responsive. They add IPC design overhead and don't share the main process's module cache or environment by default, so reach for them when you have a measured need, not preemptively.
Also note that sandbox: true (default in current Electron) restricts preload scripts to a polyfilled require covering only a subset of modules — electron, and events-style utilities. If your preload imports arbitrary npm packages, you'll need a bundler (esbuild or vite handle this well) to inline them at build time.
What to do Monday morning
Audit one window in your app. Confirm typeof require === 'undefined' in its DevTools. List every key on your exposed window.api and delete any that the renderer doesn't actually call. Then try to pass a function through the bridge deliberately and watch the DataCloneError — it's the cheapest way to build intuition for where the boundary is. Finally, pin your exact Electron version in package.json; the major release cadence is roughly every few months, and preload/IPC behavior is exactly the kind of surface that changes. The bridge is small. Keep it that way, and most of Electron's security model works for you instead of surprising you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.