Steven's Knowledge

Debugging

Debugging both Electron processes — DevTools, the main-process inspector, preload quirks, logging, crash reporting, and reproducing production-only bugs

Debugging

Electron is two runtimes glued together, so "debugging" means two completely different toolchains: Chromium's DevTools for the renderer and Node's inspector for the main process. The hard part is the seam between them — the preload — and the bugs that only appear in a packaged, signed build.

Debugging the Renderer

The renderer is a Chromium page, so it debugs exactly like a web app: DevTools, breakpoints, the console, the network panel, and source maps all work. Open DevTools programmatically or let the user toggle it.

// main process
const win = new BrowserWindow({ webContents: { /* ... */ } } as never)

if (!app.isPackaged) {
  win.webContents.openDevTools({ mode: 'detach' })
}

A keyboard shortcut is handy during development:

win.webContents.on('before-input-event', (event, input) => {
  if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
    win.webContents.toggleDevTools()
    event.preventDefault()
  }
})

React / Vue DevTools

The browser extensions do not auto-install in Electron. Use electron-devtools-installer to load them after the app is ready, in development only.

import installExtension, {
  REACT_DEVELOPER_TOOLS,
  VUEJS_DEVTOOLS,
} from 'electron-devtools-installer'

app.whenReady().then(async () => {
  if (!app.isPackaged) {
    try {
      await installExtension(REACT_DEVELOPER_TOOLS)
      // or VUEJS_DEVTOOLS for Vue apps
    } catch (err) {
      console.error('devtools install failed', err)
    }
  }
  createWindow()
})

Never ship DevTools extension installs in production. Gate every installExtension and openDevTools call behind !app.isPackaged. Shipped DevTools is an attack surface and a support headache.

Debugging the Main Process

The main process is plain Node, so debug it with the V8 inspector. Pass --inspect (attach any time) or --inspect-brk (pause on the first line until a debugger attaches).

# Attach whenever you like, default port 9229
electron --inspect=9229 .

# Pause before any app code runs — useful for startup bugs
electron --inspect-brk=9229 .

Then attach a client:

  • Chrome: open chrome://inspect, click Configure, add localhost:9229, then inspect the remote target.
  • VS Code: use a launch config (below).

VS Code compound config

Debug the main process and the renderer together with a compound configuration. The main process launches Electron; the renderer attaches over the remote debugging port.

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Main",
      "type": "node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
      "args": [".", "--remote-debugging-port=9223"],
      "outFiles": ["${workspaceFolder}/out/main/**/*.js"],
      "sourceMaps": true
    },
    {
      "name": "Renderer",
      "type": "chrome",
      "request": "attach",
      "port": 9223,
      "webRoot": "${workspaceFolder}/src/renderer",
      "timeout": 30000
    }
  ],
  "compounds": [
    {
      "name": "Main + Renderer",
      "configurations": ["Main", "Renderer"],
      "stopAll": true
    }
  ]
}

Pick Main + Renderer in the debug dropdown and you get breakpoints in both runtimes from one session.

Preload Debugging Quirks

The preload script runs in an isolated world — a separate JavaScript context from the page, with access to a subset of Node when contextIsolation is on (the default and the only safe choice). This trips people up:

  • Breakpoints in the preload show up in the renderer's DevTools, under the Sources panel, not the main-process inspector — the preload runs in the renderer process, just in a different context.
  • console.log from the preload appears in the renderer DevTools console, not the terminal.
  • Objects you contextBridge.exposeInMainWorld are deep-cloned across the world boundary; you cannot inspect live references from the page side. Log on the preload side to see the real values.
// preload — this log lands in the renderer DevTools console
console.log('[preload] exposing api')
contextBridge.exposeInMainWorld('api', {
  ping: () => ipcRenderer.invoke('ping'),
})

Logging

console.log is fine in development but useless in a packaged app where there is no attached terminal. Use electron-log, which writes to files, works from both processes, and rotates automatically.

// shared logging setup, imported by main and preload/renderer entry
import log from 'electron-log/main' // or 'electron-log/renderer'

log.transports.file.level = 'info'
log.transports.console.level = 'debug'
log.transports.file.maxSize = 5 * 1024 * 1024 // 5 MB, then rotate
log.errorHandler.startCatching() // capture uncaught errors + rejections

log.info('app starting', { version: app.getVersion() })

Where logs land

electron-log writes to the OS-standard log location, which is also what app.getPath('logs') returns.

OSDefault log file path
macOS~/Library/Logs/<AppName>/main.log
Windows%USERPROFILE%\AppData\Roaming\<AppName>\logs\main.log
Linux~/.config/<AppName>/logs/main.log

To capture both processes in one file, initialize electron-log/main in the main process and call log.initialize() so the renderer's logs are piped over IPC to the same transport.

// main entry, before creating windows
import log from 'electron-log/main'
log.initialize() // wires renderer console + electron-log/renderer to main transport

Crash Reporting in Production

Two failure classes need different handling:

  • JS errors — unhandled exceptions and rejections. Catch and report them yourself.
  • Native crashes — a segfault in Chromium, a native module, or the GPU process. These produce a minidump; you need crashReporter to collect and upload it.
import { crashReporter, app } from 'electron'

crashReporter.start({
  productName: 'MyApp',
  companyName: 'Acme',
  submitURL: 'https://crashes.example.com/post',
  uploadToServer: true,
})

Always install JS handlers in the main process:

process.on('uncaughtException', (err) => {
  log.error('uncaughtException', err)
  // optionally report, then decide whether to quit
})

process.on('unhandledRejection', (reason) => {
  log.error('unhandledRejection', reason)
})

Sentry

Sentry's Electron SDK is the path of least resistance: one init covers the main process, all renderers, and native crashes (it wires up crashReporter for you and uploads minidumps).

import * as Sentry from '@sentry/electron/main'
Sentry.init({ dsn: 'https://...@sentry.io/123' })
// in the renderer entry
import * as Sentry from '@sentry/electron/renderer'
Sentry.init()

Upload symbols and source maps to Sentry. Without them, native minidumps and minified JS stacks are unreadable. Use the Sentry CLI / electron-builder plugin in your release pipeline.

GPU and Chromium Issues

Rendering glitches, blank windows, and flicker are often GPU driver problems. Diagnose them:

  • Open chrome://gpu in a renderer window to see feature status and driver bugs Chromium has worked around.
  • Disable hardware acceleration to confirm a GPU root cause:
app.disableHardwareAcceleration() // call before app.whenReady
  • Get verbose Chromium logs to a file with --enable-logging:
electron . --enable-logging --v=1

Reproducing Production-Only Bugs

The single biggest source of "works on my machine" is the gap between dev and packaged paths.

DevelopmentProduction
Renderer loadloadURL('http://localhost:5173') (dev server)loadFile('out/renderer/index.html')
Source locationloose files on diskbundled, often inside app.asar
app.isPackagedfalsetrue

Things that only break in production:

  • Source maps. Bundlers strip or omit them in production builds. Emit them (sourcemap: true) and either ship them or upload to Sentry so stack traces resolve.
  • asar paths. Code that reads files with fs relative to __dirname works loose but fails inside app.asar, which is read-only. Use process.resourcesPath for unpacked resources and mark binaries with asarUnpack.
  • Absolute vs relative URLs. A renderer that hardcodes http://localhost works in dev and shows a blank screen in prod.

To debug a packaged build, enable the inspector on the binary itself:

# macOS example
/Applications/MyApp.app/Contents/MacOS/MyApp --inspect-brk=9229

Forcing Failures for Testing

To verify your crash/error handling actually fires, trigger failures deliberately:

process.crash() // hard native crash → minidump → crashReporter
process.hang()  // freeze the process → test watchdog / "not responding"
throw new Error('boom') // JS exception → uncaughtException handler

Symptom → Where to Look → Tool

SymptomWhere to lookTool
Blank renderer windowdev vs prod load path, console errorsrenderer DevTools, chrome://gpu
App won't start, no windowmain process exception at startup--inspect-brk, main log file
Works in dev, broken when packagedasar paths, source maps, app.isPackaged branchespackaged-binary --inspect-brk, electron-log
api undefined in rendererpreload failed to load / contextIsolationrenderer DevTools (preload context), preload console
Crash with no JS stacknative crash (Chromium, native module, GPU)crashReporter minidump, Sentry native
Flicker / black areas / GPU hangGPU acceleration, driver bugschrome://gpu, disableHardwareAcceleration
Silent failure in productionmissing logs, swallowed promiseelectron-log file transport, unhandledRejection
Frozen UI / "not responding"main-process blocking workmain inspector, process.hang() to reproduce

On this page