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, addlocalhost: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.logfrom the preload appears in the renderer DevTools console, not the terminal.- Objects you
contextBridge.exposeInMainWorldare 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.
| OS | Default 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 transportCrash 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
crashReporterto 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://gpuin 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=1Reproducing Production-Only Bugs
The single biggest source of "works on my machine" is the gap between dev and packaged paths.
| Development | Production | |
|---|---|---|
| Renderer load | loadURL('http://localhost:5173') (dev server) | loadFile('out/renderer/index.html') |
| Source location | loose files on disk | bundled, often inside app.asar |
app.isPackaged | false | true |
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
fsrelative to__dirnameworks loose but fails insideapp.asar, which is read-only. Useprocess.resourcesPathfor unpacked resources and mark binaries withasarUnpack. - Absolute vs relative URLs. A renderer that hardcodes
http://localhostworks 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=9229Forcing 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 handlerSymptom → Where to Look → Tool
| Symptom | Where to look | Tool |
|---|---|---|
| Blank renderer window | dev vs prod load path, console errors | renderer DevTools, chrome://gpu |
| App won't start, no window | main process exception at startup | --inspect-brk, main log file |
| Works in dev, broken when packaged | asar paths, source maps, app.isPackaged branches | packaged-binary --inspect-brk, electron-log |
api undefined in renderer | preload failed to load / contextIsolation | renderer DevTools (preload context), preload console |
| Crash with no JS stack | native crash (Chromium, native module, GPU) | crashReporter minidump, Sentry native |
| Flicker / black areas / GPU hang | GPU acceleration, driver bugs | chrome://gpu, disableHardwareAcceleration |
| Silent failure in production | missing logs, swallowed promise | electron-log file transport, unhandledRejection |
| Frozen UI / "not responding" | main-process blocking work | main inspector, process.hang() to reproduce |