Pitfalls
Real-world Electron bugs and how to avoid them — insecure webPreferences, blocking the main process, white flash, leaks, asar paths, native ABI, navigation, CSP, and dev/prod drift
Pitfalls
The bugs that cost real teams real days. Each entry is a failure mode you will hit in production if you don't design against it, paired with the cause and the fix.
Disabling the security defaults
The single most damaging mistake: turning off contextIsolation or turning on nodeIntegration so the renderer can "just use require."
// CATASTROPHIC — never ship this
new BrowserWindow({
webPreferences: {
nodeIntegration: true, // page JS gets require(), process, child_process
contextIsolation: false, // page can reach into preload internals
},
});With these settings, a single XSS bug — or one malicious transitive npm dependency that runs in the renderer — gives an attacker require('child_process').exec(...) on the user's machine. The modern defaults (contextIsolation: true, nodeIntegration: false) are safe; the job is to leave them on and add sandbox: true.
Treat the renderer as hostile. Anything that runs in the renderer — your code, an ad, a compromised dependency — must be unable to reach Node. Keep contextIsolation and sandbox on, nodeIntegration off, and expose only narrow functions through contextBridge.
Reaching for @electron/remote
The old remote module let renderer code call main-process objects synchronously, as if they were local. It is removed from modern Electron, and @electron/remote revives it as a separate package. Avoid it.
Every "remote" call is a hidden synchronous IPC round-trip that blocks the renderer, and it punches a wide hole in your security boundary (the renderer can drive main-process objects directly). Replace it with explicit, async ipcRenderer.invoke / ipcMain.handle calls exposed through a narrow preload API.
Blocking the main process
The main process runs the event loop that services every window. Any synchronous, long-running call there freezes the entire UI — all windows, the menu, the tray — until it returns.
// renderer — sendSync blocks this renderer until main replies
const data = ipcRenderer.sendSync('read-big-file');
// main — synchronous fs blocks the event loop for every window
ipcMain.on('read-big-file', (event) => {
event.returnValue = fs.readFileSync(hugePath, 'utf8'); // freezes everything
});Use async invoke/handle, async fs.promises, and move CPU/IO-heavy work to a utilityProcess or worker_threads. Reserve sendSync and readFileSync for tiny, instant operations at startup only.
White flash on launch
A BrowserWindow paints a blank white surface the moment it is created, before your content renders. Users see a jarring flash.
const win = new BrowserWindow({ show: false, backgroundColor: '#1e1e1e' });
win.once('ready-to-show', () => win.show());
win.loadFile('index.html');Create the window hidden, set a backgroundColor matching your shell, and show it on ready-to-show.
Memory leaks from listeners and webContents
Two classic leaks:
// LEAK 1: ipcMain listeners added per-window, never removed.
// Each new window adds another handler that keeps the old window alive.
function createWindow() {
const win = new BrowserWindow();
ipcMain.on('ping', () => win.webContents.send('pong')); // never removed → leak
}
// FIX: register handlers once at startup, or remove on window close.
ipcMain.handle('ping', () => 'pong'); // one registration for the app
// LEAK 2: holding webContents / BrowserWindow references after close.
const windows = new Set<BrowserWindow>();
function track(win: BrowserWindow) {
windows.add(win);
win.on('closed', () => windows.delete(win)); // drop the reference
}A removeAllListeners on a channel you re-register per window, or forgetting the closed cleanup, keeps destroyed windows in memory. Register IPC handlers once; always clear references in closed.
Paths inside an asar archive
In a packaged app your code lives inside an app.asar archive, which behaves like a read-only virtual directory. __dirname points inside the archive — fine for reading bundled files with Electron's patched fs, but you cannot write there, and external tools (a spawned binary, a native helper) cannot see paths inside it.
import { app } from 'electron';
import path from 'node:path';
// Bundled, read-only resource shipped with the app:
const tpl = path.join(process.resourcesPath, 'templates', 'invoice.html');
// Your own source files (works in dev and inside asar):
const helper = path.join(app.getAppPath(), 'helpers', 'index.js');
// Anything you WRITE goes to userData, never next to __dirname:
const db = path.join(app.getPath('userData'), 'data.sqlite');For files a child process or native module must reach by real filesystem path, mark them asarUnpack in your packager config so they land in app.asar.unpacked on disk.
Native modules not rebuilt for Electron's ABI
Native (.node) modules are compiled against a specific V8/Node ABI. Electron bundles its own Node with a different ABI than your system Node, so a module that installed fine with npm install will throw was compiled against a different Node.js version at runtime.
# Rebuild native modules against Electron's ABI
npx @electron/rebuild
# Or let electron-builder do it during packaging
electron-builder install-app-depsRun @electron/rebuild after installing any native dependency, and wire it into postinstall so CI builds match.
Loading remote or untrusted URLs
Pointing a privileged BrowserWindow at a remote site you don't fully control inherits all that window's capabilities to that page. If you must embed remote content, isolate it: a separate partition, no preload, sandbox: true, and a strict CSP, so it can render and nothing more.
Uncontrolled navigation and window.open
By default a page can navigate itself anywhere and call window.open. Both are escape hatches: navigation can swap your trusted origin for an attacker's, and window.open can spawn windows with unexpected privileges.
app.on('web-contents-created', (_e, contents) => {
contents.on('will-navigate', (event, url) => {
if (new URL(url).origin !== 'https://app.example.com') event.preventDefault();
});
contents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url); // hand trusted links to the real browser
return { action: 'deny' };
});
});Always set will-navigate and setWindowOpenHandler. Forgetting either leaves the door open.
Missing Content-Security-Policy
Without a CSP, an injected <script> or an eval runs unhindered — turning a markup-injection bug into full code execution in the renderer. Set a strict CSP via a response header (and avoid unsafe-eval / unsafe-inline).
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': ["default-src 'self'", "script-src 'self'"].join('; '),
},
});
});Dev vs prod path differences
In development you load a dev-server URL; in production you load a packaged file. Hardcoding one breaks the other — the classic "blank window in the packaged build."
if (app.isPackaged) {
win.loadFile(path.join(__dirname, '../renderer/index.html')); // prod: file://
} else {
win.loadURL(process.env.ELECTRON_RENDERER_URL!); // dev: http://localhost
}Branch on app.isPackaged. Note loadFile resolves correctly against __dirname, while a hardcoded loadURL('http://localhost:5173') will 404 in production.
GPU and hardware-acceleration crashes
Some GPU drivers (older Intel, certain virtualized environments) crash Chromium's compositor. The app shows a blank window or dies on launch on those machines only.
// Last-resort fallback for affected machines — disable GPU acceleration
app.disableHardwareAcceleration();Don't disable it globally by default (you lose performance). Detect the affected case (e.g., a gpu-process-crashed event or a known-bad config) and relaunch with acceleration off, or expose it as a setting.
Unhandled promise rejections in main
An unhandled rejection in the main process can crash the app with no log in production. Install last-resort handlers.
process.on('uncaughtException', (err) => log.error('uncaught', err));
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', reason));These are a safety net, not a strategy — still await and try/catch your async work — but they turn a silent crash into a diagnosable log entry.
Shipping devDependencies
Bundling your build tools, test frameworks, and source maps into the packaged app bloats the download and can leak internals. electron-builder ships only dependencies by default, so keep build-time packages in devDependencies and runtime packages in dependencies. Verify the packaged app.asar contents before release.
Symptom → Cause → Fix
| Symptom | Cause | Fix |
|---|---|---|
Renderer can require('fs') / RCE on XSS | nodeIntegration: true or contextIsolation: false | Keep secure defaults; add sandbox: true; narrow contextBridge |
| Whole UI freezes for seconds | sendSync / readFileSync on main | Async invoke/handle + fs.promises; offload to utilityProcess |
| Brief white box on launch | Window shown before paint | show: false + ready-to-show + backgroundColor |
| Memory grows per window opened | IPC listeners re-registered, refs not cleared | Register handlers once; clean up in closed |
ENOENT / cannot write file in prod | Writing next to __dirname inside asar | Write to app.getPath('userData'); read bundled via process.resourcesPath |
compiled against a different Node.js version | Native module not rebuilt for Electron ABI | npx @electron/rebuild / install-app-deps |
| Blank window only in packaged build | loadURL hardcoded to dev server | Branch on app.isPackaged (loadFile vs loadURL) |
| Page navigates away / spawns windows | No will-navigate / setWindowOpenHandler | Block navigation; deny window.open, route via shell.openExternal |
| Injected script executes | No / weak CSP | Strict CSP header, no unsafe-eval/unsafe-inline |
| Crashes on some machines only | GPU driver / compositor crash | app.disableHardwareAcceleration() as fallback |
| Silent crash, no logs | Unhandled rejection in main | uncaughtException / unhandledRejection handlers + electron-log |
| Bloated download, leaked internals | devDependencies packaged | Keep build tools in devDependencies; verify asar contents |
Most of these are template problems, not coding problems. Bake the secure webPreferences, the show: false/ready-to-show pattern, the navigation handlers, the app.isPackaged branch, and the @electron/rebuild postinstall into your project scaffold. Then every new window and every CI build inherits the fix instead of relying on someone to remember it.