Migrating Legacy Electron Apps to Context Isolation: A Practical Engineering Decision Guide
Practical migration guide for moving legacy Electron apps to context isolation with secure IPC design, version-aware configuration, and incremental renderer refactoring.
09 Jan 2026, 18:01 UTC

The Migration Problem
Electron v12 (released 2021) changed the default for contextIsolation to true. By Electron v30+ (current as of 2026), contextIsolation: false is incompatible with nodeIntegration: true and the sandbox is increasingly enforced. Legacy applications that rely on require() or process in renderer code will fail silently or crash. This guide covers the engineering decisions required to migrate a legacy codebase to the isolated architecture without rewriting the entire frontend.
Desired Outcome
- Renderer processes cannot access Node.js globals (
require,process,fs,child_process). - Preload scripts expose a minimal, typed API surface via
contextBridge. - Existing renderer modules continue to work with minimal changes.
- No regression to
contextIsolation: falsein production.
Prerequisites
- Electron >= 20.0.0 (v30+ recommended; v20+ has stricter sandbox defaults).
- Source access to
main.js,preload.js, and renderer entry points. - Ability to run the app with
ELECTRON_ENABLE_LOGGING=1for IPC debugging. - Node.js >= 18 (matches Electron's bundled Node version).
Step 1: Audit Renderer-Side Node Usage
Before changing configuration, identify every renderer file that imports Node built-ins or uses process, Buffer, __dirname, or require. Run a static search across your renderer source tree:
# Run from project root
rgrep -r "require\(['\"]\(fs\\|path\\|child_process\\|os\\|crypto\\|electron\)['\"]\)" src/renderer/
rgrep -r "process\." src/renderer/
rgrep -r "__dirname\\|__filename" src/renderer/
Catalog each hit. Typical categories:
- Configuration reads —
fs.readFileSyncfor local JSON files. - Native module calls —
require('native-module')for native addons. - Environment checks —
process.env.NODE_ENV,process.platform. - Third-party libraries — Packages that assume Node globals (e.g., older
axioswithhttpadapter).
Decision point: For each hit, decide whether to (a) move the logic to the main process and expose via IPC, (b) polyfill in preload, or (c) replace with a browser-compatible alternative.
Step 2: Harden BrowserWindow Configuration
In main.js, set the minimal secure baseline. Do not rely on defaults; explicit configuration prevents regressions during upgrades.
// main.js
const { BrowserWindow, app } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
contextIsolation: true, // Required: isolates renderer from preload
nodeIntegration: false, // Required: disables Node in renderer
sandbox: true, // Recommended: enables OS-level sandbox (macOS/Windows/Linux)
preload: path.join(__dirname, 'preload.js'),
// webSecurity: true, // Default true; keep unless you have a specific reason
// allowRunningInsecureContent: false // Default false
}
});
// CSP header for defense-in-depth
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': ["default-src 'self'; script-src 'self'"]
}
});
});
win.loadFile(path.join(__dirname, 'renderer/index.html'));
}
app.whenReady().then(createWindow);
Note: sandbox: true requires the preload script to be sandbox-compatible (no Node APIs except ipcRenderer, contextBridge, and a few others). If your preload currently uses fs or path, move that logic to the main process.
Step 3: Design the ContextBridge API Surface
Create a typed, versioned API namespace. Avoid exposing ipcRenderer directly; wrap each channel with input validation and error normalization.
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
// Type definitions for renderer (also used by TS if applicable)
// interface ElectronAPI {
// config: { get: () => Promise };
// notifications: { send: (msg: string) => void };
// fs: { readTextFile: (relativePath: string) => Promise };
// }
const electronAPI = {
config: {
get: () => ipcRenderer.invoke('config:get'),
},
notifications: {
send: (message: string) => {
if (typeof message !== 'string' || message.length > 500) return;
ipcRenderer.send('notification:send', message);
},
},
fs: {
// Only allow reading from a known safe directory
readTextFile: (relativePath: string) => {
if (!relativePath || relativePath.includes('..')) {
return Promise.reject(new Error('Invalid path'));
}
return ipcRenderer.invoke('fs:readTextFile', relativePath);
},
},
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
// Optional: expose a minimal process polyfill for environment checks
contextBridge.exposeInMainWorld('process', {
env: { NODE_ENV: process.env.NODE_ENV },
platform: process.platform,
versions: { electron: process.versions.electron },
});
Engineering decision: The process polyfill above is a controlled leak. Only expose the specific properties your renderer code actually reads. Do not expose process.argv, process.execPath, or process.cwd().
Step 4: Implement Main-Process Handlers
Each invoke channel needs a handler in main.js. Validate inputs again; the renderer is untrusted.
// main.js (additions)
const { ipcMain } = require('electron');
const fs = require('fs').promises;
const path = require('path');
const ALLOWED_READ_ROOT = path.join(__dirname, 'resources');
ipcMain.handle('config:get', async () => {
const configPath = path.join(ALLOWED_READ_ROOT, 'config.json');
try {
const data = await fs.readFile(configPath, 'utf8');
return JSON.parse(data);
} catch (err) {
console.error('Config read failed:', err);
return { theme: 'system', lang: 'en' }; // safe defaults
}
});
ipcMain.handle('fs:readTextFile', async (_event, relativePath) => {
// Defense in depth: normalize and verify containment
const requested = path.normalize(path.join(ALLOWED_READ_ROOT, relativePath));
if (!requested.startsWith(ALLOWED_READ_ROOT)) {
throw new Error('Path traversal attempt');
}
return fs.readFile(requested, 'utf8');
});
ipcMain.on('notification:send', (_event, message) => {
// Could forward to native notification API
console.log('[Notification]', message);
});
Step 5: Update Renderer Imports Incrementally
Replace each Node usage with a call to window.electronAPI. For modules that cannot be easily refactored (e.g., a large utility file), create a shim in the preload that re-exports only the needed functions.
// renderer/utils/config.js (before)
// const fs = require('fs');
// export const loadConfig = () => JSON.parse(fs.readFileSync('./config.json', 'utf8'));
// renderer/utils/config.js (after)
export const loadConfig = async () => {
return window.electronAPI.config.get();
};
For third-party libraries that require Node globals, consider:
- Updating to a newer version that supports browser environments.
- Bundling with a tool like
webpackorvitethat polyfillsprocessandBuffer. - If the library uses
fs/netat runtime, move that usage to the main process.
Verification Checklist
Run the app with DevTools open (Ctrl+Shift+I / Cmd+Option+I) and execute in the Console:
console.log(typeof require);→"undefined"console.log(typeof process);→"object"(your polyfill) or"undefined"console.log(window.electronAPI);→ object with your defined namespaceswindow.electronAPI.config.get().then(console.log);→ resolves with config objectwindow.electronAPI.fs.readTextFile('../../etc/passwd').catch(console.error);→ rejects with "Path traversal attempt"
Additionally, enable IPC logging to trace channel traffic:
# Terminal
ELECTRON_ENABLE_LOGGING=1 npm start 2>&1 | grep -i ipc
Limitations and Known Gaps
- Native Node addons cannot be loaded in a sandboxed preload. If your app uses
require('native-addon')in the renderer, you must either move the addon usage to the main process or disablesandbox: truefor that specific window (reduces security). contextBridgeonly supports serializable values (primitives, arrays, objects, Promises). Functions, class instances, and DOM nodes cannot be passed across the bridge.- Electron v27+ deprecated
remotemodule entirely. If your legacy code uses@electron/remote, migrate to explicit IPC. - TypeScript projects need
declare global { interface Window { electronAPI: ElectronAPI; process: NodeJS.Process; } }in apreload.d.tsfile for type safety.
Rollback / Diagnostic Procedure
If the app fails to start after migration, use this temporary diagnostic sequence. Do not ship with these settings.
- In
main.js, changesandbox: true→sandbox: false. - Change
contextIsolation: true→contextIsolation: false. - Set
nodeIntegration: true. - Restart. If the app loads, the breakage is in the isolation layer (preload bridge or main handlers).
- Re-enable
contextIsolation: truefirst, test. Thensandbox: true. - Use
webContents.executeJavaScript('console.log(window.electronAPI)')from main to verify bridge injection.
Once the specific failure is identified, restore secure defaults and fix the bridge.
Version Assumptions
- Electron 30.x (current stable as of 2026-10-10).
- Node.js 18.x bundled in Electron 30.
- Chromium 118+ (matches Electron 30).
- APIs used (
contextBridge,ipcRenderer.invoke,sandbox) are stable since Electron 12/14/20 respectively.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.