DEV Community

Cover image for Debugging Electron Apps: A Practical Guide to Main, Renderer, and Production
mp3sss
mp3sss

Posted on AI-assisted

Debugging Electron Apps: A Practical Guide to Main, Renderer, and Production

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

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 ."
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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' });
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  1. 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.
  2. 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:
  3. Redux DevTools (or your framework's equivalent) — time-travel state inspection.
  4. 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);
Enter fullscreen mode Exit fullscreen mode

Note: loading extensions from a hard-coded Chrome path works for local dev. For CI or a team, vendor the .crx into your repo and point loadExtension at 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),
  });
});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You now get:

  • console.log from main and every renderer printed to the terminal.
  • A Chromium DevTools endpoint on localhost:9222 that you can attach to from any Chrome window.
  • GPU errors and sandbox violations that double-clicking swallows. ### Step 2: --enable-logging is 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=1 for 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 in main.js:42:18 and you cannot tell which .ts file that is, you forgot source maps. Fix:
// electron-builder.json
{
  "files": ["out/**/*", "package.json"],
  "asarUnpack": ["out/**/*.map"]
}
Enter fullscreen mode Exit fullscreen mode

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/**"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Headless bug repro: SSH into a staging machine, launch the app with --remote-debugging-port=9222, tunnel the port back to your laptop with ssh -L 9222:localhost:9222 staging, and debug the production build as if it were on your machine.
  2. Automated UI inspection: script CDP with chrome-remote-interface to click through a flow and dump the console. This is what the better E2E frameworks use under the hood. ### Security warning --remote-debugging-port binds 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.1 in dev, and never ship the flag in a release build.
electron . --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1
Enter fullscreen mode Exit fullscreen mode

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:

  1. Launch with --enable-logging --v=1.
  2. Open chrome://tracing in the app (only available when DevTools is open).
  3. 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));
}
Enter fullscreen mode Exit fullscreen mode

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/json and 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)

Collapse
 
alexshev profile image
Alex Shev •

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.