Every Electron developer has lived this moment: the app works in dev, then ships to a user and misbehaves in a way you cannot reproduce. Stack Overflow answers fragment the debugging story across a dozen flags and launch args, and half of them are stale. This is the field guide I wish I'd had at version 1.0 — one reference for the main process, the renderer, utility processes, packaged builds, and the small set of tricks that turn "it works on my machine" into a reproducible bug.
TL;DR
-
Main process: launch with
--inspect=5858and attach VS Code or Chrome DevTools toabout:inspect. Breakpoints inmain.tswork the same way as in Node. -
Renderer:
mainWindow.webContents.openDevTools({ mode: 'detach' })— but gate it behind an env var or a hidden menu item so it never ships to users. -
Packaged app: re-run the installed binary from a terminal with
--enable-logging --remote-debugging-port=9222;console.logfrom main and renderer will both land in stdout, and Chrome onlocalhost:9222gets you full DevTools. - Production debugging: don't attach to a stranger's machine. Ship a "Diagnostics" modal that captures a crash dump, a recent log tail, and the GPU process info, and send it to your crash endpoint.
-
The two traps that cost me days: (1) breakpoints in main hit only when you launch Electron from the same Node version that built the app; (2) renderer DevTools disconnects on reload unless you pass
--remote-debugging-portand attach to the port, not the window. ## Table of contents - The three surfaces you are actually debugging
- Debugging the main process
- Debugging renderer processes
- Utility processes, child processes, and workers
- Debugging a packaged build
- Remote debugging over the DevTools Protocol
- Profiling: CPU, heap, and tracing
- Production diagnostics without shipping DevTools
- Common pitfalls
10. Checklist
The three surfaces you are actually debugging
An Electron app is not one program. It is a graph of OS processes that share a Chromium message loop. When something goes wrong, the first question is always: which process is broken?
| Process | Runs | What it can do | How you debug it |
|---|---|---|---|
| Main | Node.js, single thread | filesystem, IPC orchestration, windows lifecycle | --inspect=5858, VS Code attach |
| Renderer(s) | Chromium, one per BrowserWindow | DOM, web APIs, your UI code | DevTools, openDevTools() |
| GPU process | Chromium compositor | drawing, WebGL | --disable-gpu to disable, chrome://gpu |
| Utility process | Node.js child | utilityProcess.fork(), offloaded work | --inspect inherited or passed explicitly |
| Utility (Node) child | child_process.fork / spawn | native addons, long jobs | stdio + --inspect |
Most "Electron bugs" are really one of three things: a main-process deadlock, a renderer exception you never saw because DevTools was closed, or a GPU compositor crash that only manifests on a specific driver. The tools below cover all three.
Debugging the main process
The main process is just Node. Every Node debugging technique works, with one wrinkle: you launch it through the electron binary, not node.
Minimal launch script
// package.json
{
"scripts": {
"dev": "electron .",
"dev:debug": "electron --inspect=5858 .",
"dev:debug-brk": "electron --inspect-brk=5858 ."
}
}
Then open chrome://inspect in any Chromium browser, click Configure…, add localhost:5858, and you should see your main process appear. Click inspect and you get a full Node DevTools window with breakpoints, scope inspection, and an ES module-aware debugger.
VS Code launch config
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
"args": ["."],
"outputCapture": "std",
"resolveSourceMaps": true
},
{
"name": "Electron: Attach to Main",
"type": "node",
"request": "attach",
"port": 5858,
"address": "localhost",
"sourceMaps": true,
"timeout": 30000
}
]
}
outputCapture: "std" is the undocumented flag that makes console.log from main show up in the VS Code Debug Console. Without it, you are blind to stdout.
Source maps for TypeScript
If you write main in TypeScript, your build step (tsc, esbuild, tsup) must emit sourceMap: true. The debugger uses the emitted .js.map to map a breakpoint in main.ts to the running bundle. One gotcha: Electron's asar packaging does not preserve source maps unless you either (a) unpack the directory (asarUnpack) or (b) ship *.map next to the JS. For local dev this is free; for packaged builds, see Debugging a packaged build.
Debugging renderer processes
Renderer debugging is the closest thing to regular web work. The renderer is a Chromium tab.
Open DevTools programmatically
// main process
import { BrowserWindow } from 'electron';
function createWindow() {
const win = new BrowserWindow({ width: 1280, height: 800 });
win.loadURL('https://localhost:5173');
if (process.env.VITE_DEV_SERVER_URL) {
// Dev mode: dock DevTools to the right
win.webContents.openDevTools({ mode: 'right' });
} else {
// Production: only open on demand via a hidden accelerator
win.webContents.on('before-input-event', (_e, input) => {
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
win.webContents.openDevTools({ mode: 'detach' });
}
});
}
}
Two details matter:
-
mode: 'detach'opens DevTools in its own OS window. This is what you want when debugging a full-screen kiosk app, because docking pushes the content out of view. -
Don't auto-open DevTools in production builds. It ships a powerful inspector to end users. Gate it behind an env var, a build flag, or an undiscoverable keyboard shortcut.
### DevTools extensions
Chrome DevTools Extensions (
.crx) work in Electron. The two I install on every project: - Redux DevTools (or your framework's equivalent) — time-travel state inspection.
- React DevTools / Vue DevTools — component tree inspection. Load them in main before the window loads:
import { session } from 'electron';
const reactDevToolsPath = path.join(
os.homedir(),
'AppData/Local/Google/Chrome/User Data/Default/Extensions/' +
'fmkadmapgofadopljbjfkapdkoienihi/6.1.0_0'
);
await session.defaultSession.loadExtension(reactDevToolsPath);
Note: loading extensions from a hard-coded Chrome path works for local dev. For CI or a team, vendor the
.crxinto your repo and pointloadExtensionat the unpacked directory.Renderer exceptions you never saw
By default, an uncaught exception in the renderer prints to the DevTools console — but only if DevTools is open. In production, the user sees nothing. Capture it globally in the renderer entry:
// renderer entry
window.addEventListener('error', (event) => {
window.ipcRenderer.invoke('app:report-error', {
source: 'renderer',
message: event.message,
stack: event.error?.stack,
filename: event.filename,
lineno: event.lineno,
});
});
window.addEventListener('unhandledrejection', (event) => {
window.ipcRenderer.invoke('app:report-error', {
source: 'renderer-promise',
reason: String(event.reason),
});
});
Wire app:report-error in main to your crash/error endpoint (see the logging & crash-reporting article for that half).
Utility processes, child processes, and workers
When you move work off the main thread with utilityProcess.fork() (the modern, sandboxed choice) or child_process.fork() (the Node classic), you inherit one problem: breakpoints in those files are not attached by default.
utilityProcess debug port
utilityProcess.fork() does not automatically pass --inspect. You have to:
// main process
import { utilityProcess } from 'electron';
const child = utilityProcess.fork(path.join(__dirname, 'workers/heavy.js'), [], {
stdio: 'pipe',
// Electron >= 30: pass node args
execArgv: ['--inspect=5859'],
});
child.on('message', (msg) => console.log('worker said', msg));
Then attach to localhost:5859 in chrome://inspect. If execArgv is not honored on your Electron version, the fallback is to set the env var NODE_OPTIONS='--inspect=5859' before forking — but only in dev, because it leaks the inspector to every descendant.
Worker threads
Node worker threads (new Worker(...)) share the main process's V8 isolate group. Their console output lands in main's stdout. They do not get a separate inspector; if you need to debug one, put a debugger statement in the worker and launch the main process with --inspect, which pauses in the worker as well.
Debugging a packaged build
This is where the pain starts. Your dev script runs electron ., but the shipped app is an .exe / .app / .deb built by electron-builder or electron-forge.
Step 1: Run the packaged binary from a terminal
The single most useful thing you can do is stop launching the app by double-clicking. Open a terminal and run the binary directly:
# Windows
& "C:\Users\<you>\AppData\Local\Programs\MyApp\MyApp.exe" --enable-logging --remote-debugging-port=9222
# macOS
/Applications/MyApp.app/Contents/MacOS/MyApp --enable-logging --remote-debugging-port=9222
# Linux
/opt/MyApp/myapp --enable-logging --remote-debugging-port=9222
You now get:
-
console.logfrom main and every renderer printed to the terminal. - A Chromium DevTools endpoint on
localhost:9222that you can attach to from any Chrome window. - GPU errors and sandbox violations that double-clicking swallows.
### Step 2:
--enable-loggingis not optional Without--enable-logging, Chromium suppresses most of its internal log. With it, you see: - sandbox denials
- GPU process crashes
- network service errors
- utility process exits
On Windows, also add
--v=1for verbose logging. It is noisy; use it when the default level hides the bug. ### Step 3: Source maps in the asar If your packaged app throws an error inmain.js:42:18and you cannot tell which.tsfile that is, you forgot source maps. Fix:
// electron-builder.json
{
"files": ["out/**/*", "package.json"],
"asarUnpack": ["out/**/*.map"]
}
Then the .map files sit next to the JS inside the asar and the inspector resolves them.
Step 4: Unpack the native modules
Native .node addons (better-sqlite3, node-pty, robotjs) cannot be dlopen'd from inside an asar. Electron handles this transparently in recent versions, but if you hit Cannot open module ... asar/...node, add the module to asarUnpack:
{
"asarUnpack": ["node_modules/better-sqlite3/**"]
}
Remote debugging over the DevTools Protocol
The DevTools Protocol (CDP) is the same API Chrome exposes. Electron lets you turn it on with one flag.
Launch with the port open
electron . --remote-debugging-port=9222
Then open http://localhost:9222/json in any browser. You get a JSON list of every debuggable target — main process, each renderer, each service worker. Click the webSocketDebuggerUrl and you are in.
Two uses I reach for weekly
-
Headless bug repro: SSH into a staging machine, launch the app with
--remote-debugging-port=9222, tunnel the port back to your laptop withssh -L 9222:localhost:9222 staging, and debug the production build as if it were on your machine. -
Automated UI inspection: script CDP with
chrome-remote-interfaceto click through a flow and dump the console. This is what the better E2E frameworks use under the hood. ### Security warning--remote-debugging-portbinds to all interfaces by default in older Electron versions. Any process on the machine can drive your app. Always add--remote-debugging-address=127.0.0.1in dev, and never ship the flag in a release build.
electron . --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1
Profiling: CPU, heap, and tracing
CPU profile
In DevTools (main or renderer), open the Performance tab, record for 5–10 seconds while reproducing the bug, and look for long yellow (scripting) tasks. For the main process specifically, the CPU profiler tab in the Node inspector gives a flamegraph.
Heap snapshot
When you suspect a leak: take a heap snapshot, do the suspect action, force GC, take a second snapshot. Switch the dropdown from Summary to Comparison and sort by Delta. Anything that grew and did not shrink back is a candidate leak.
We covered the full leak-hunt workflow in the memory-management article; the key reminder is that Electron adds native retained memory (C++ side of BrowserWindow, WebContents, Tray) that V8 heap snapshots will not show up. Use process.getProcessMemoryInfo() and compare workingSetSize across the suspect action.
Chromium tracing
For compositor jank or GPU bugs, the nuclear option is chrome://tracing:
- Launch with
--enable-logging --v=1. - Open
chrome://tracingin the app (only available when DevTools is open). - Record a trace while the user reproduces the jank.
4. Save the trace as JSON and share it. This is what the Electron team asks for when you file a performance bug.
Production diagnostics without shipping DevTools
You cannot attach a debugger to a user's machine. You can, however, ship a tiny diagnostics kit.
// main process
import { app, dialog, crashReporter } from 'electron';
import * as fs from 'fs';
crashReporter.start({
productName: 'MyApp',
submitURL: 'https://crash.example.com/submit',
uploadToServer: true,
});
async function collectDiagnostics() {
const profile = app.getPath('userData');
const logTail = fs
.readFileSync(path.join(profile, 'logs', 'main.log'), 'utf8')
.split('\n')
.slice(-200)
.join('\n');
const mem = process.getProcessMemoryInfo();
const gpu = await app.getGPUInfo('complete');
return {
version: app.getVersion(),
platform: process.platform,
arch: process.arch,
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
memory: mem,
gpu,
logTail,
};
}
// hidden menu item, not a user-facing button
async function onCollectDiagnostics() {
const data = await collectDiagnostics();
const { filePath } = await dialog.showSaveDialog({
defaultPath: `myapp-diagnostics-${Date.now()}.json`,
});
if (filePath) fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
}
This is what you ask a user to send you when they report a bug. It has the version, the memory, the GPU info, and the last 200 lines of log — enough to reproduce 80% of issues without ever opening DevTools on their machine.
Common pitfalls
| Pitfall | What you see | Fix |
|---|---|---|
| Breakpoint in main.ts never hits | Debugger connects, pause button does nothing | You launched electron . without --inspect, or you are attaching to the wrong port. Confirm the inspector is listening (`netstat -an \ |
| Renderer DevTools closes on reload | Open DevTools, hit Cmd+R, DevTools is gone | Pass {% raw %}--remote-debugging-port=9222 and attach via Chrome; the in-app DevTools still detaches on reload. |
console.log from main invisible in VS Code |
Debug Console is empty | Add outputCapture: "std" to the launch config. |
| Native module crashes only in packaged build | Works in npm run dev, dies after installer |
The module was not unpacked from asar. Add to asarUnpack. |
| Source maps point at wrong lines | Breakpoints land on the wrong statement | Your bundler is emitting sourcesContent but rewriting paths; set sourceRoot explicitly. |
--remote-debugging-port ignored |
Port 9222 not listening | You are passing it after the app path (electron . --remote-debugging-port=9222); Electron args go before the app path. |
| Utility process breakpoints never hit | Worker runs but debugger does not stop |
utilityProcess.fork() needs execArgv: ['--inspect=5859'] explicitly. |
| GPU process crash only on Windows | App blanks out on a specific laptop | Reproduce with --disable-gpu to confirm; file an Electron bug with the chrome://gpu JSON. |
Checklist
Use this when you are staring at a bug you cannot reproduce:
- [ ] Reproduce locally with
electron --inspect=5858 .and confirm whether main or renderer is throwing. - [ ] If it works locally, reproduce the packaged binary from a terminal with
--enable-logging --remote-debugging-port=9222. - [ ] Attach to
localhost:9222/jsonand identify which process owns the failing frame. - [ ] Check whether a native module is involved — if yes, verify
asarUnpack. - [ ] Capture a heap snapshot or CPU profile while reproducing, even if you think it is a logic bug.
- [ ] Ask the user for a diagnostics bundle (
version, GPU info, log tail, memory) before asking for a screen recording. - [ ] Never ship
--remote-debugging-port,--inspect, or auto-opened DevTools in a release build. Debugging Electron is not a different skill from debugging Node or the web — it is the same three tools (inspector, DevTools, CDP) aimed at five different processes, plus a small set of packaging gotchas that only show up after you ship. Set up the launch configs once, internalize the "run the binary from a terminal" reflex, and most bugs stop being mysterious.
Top comments (1)
The main and renderer split becomes much easier to debug when logs share a correlation ID and report build metadata. A production guide should also cover crash recovery and source-map handling, otherwise the exact failures users see are the hardest ones to connect back to code.