Steven's Knowledge

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-deps

Run @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

SymptomCauseFix
Renderer can require('fs') / RCE on XSSnodeIntegration: true or contextIsolation: falseKeep secure defaults; add sandbox: true; narrow contextBridge
Whole UI freezes for secondssendSync / readFileSync on mainAsync invoke/handle + fs.promises; offload to utilityProcess
Brief white box on launchWindow shown before paintshow: false + ready-to-show + backgroundColor
Memory grows per window openedIPC listeners re-registered, refs not clearedRegister handlers once; clean up in closed
ENOENT / cannot write file in prodWriting next to __dirname inside asarWrite to app.getPath('userData'); read bundled via process.resourcesPath
compiled against a different Node.js versionNative module not rebuilt for Electron ABInpx @electron/rebuild / install-app-deps
Blank window only in packaged buildloadURL hardcoded to dev serverBranch on app.isPackaged (loadFile vs loadURL)
Page navigates away / spawns windowsNo will-navigate / setWindowOpenHandlerBlock navigation; deny window.open, route via shell.openExternal
Injected script executesNo / weak CSPStrict CSP header, no unsafe-eval/unsafe-inline
Crashes on some machines onlyGPU driver / compositor crashapp.disableHardwareAcceleration() as fallback
Silent crash, no logsUnhandled rejection in mainuncaughtException / unhandledRejection handlers + electron-log
Bloated download, leaked internalsdevDependencies packagedKeep 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.

On this page