DEV Community

Cover image for Electron Memory Management: A Practical Guide to Finding Leaks and Keeping Your App Slim
mp3sss
mp3sss

Posted on AI-assisted

Electron Memory Management: A Practical Guide to Finding Leaks and Keeping Your App Slim

Electron Memory Management: A Practical Guide to Finding Leaks and Keeping Your App Slim

Electron apps don't leak memory because of one bug — they leak because of a dozen small ones, amplified by the fact that every window spins up its own Chromium process. Here's how to find them, fix them, and prove it.

If you've ever watched your Electron app's RAM climb from 150 MB at launch to 900 MB after an afternoon of use, you know the feeling: it's not a crash, it's a slow bleed. Memory issues in Electron are notoriously hard to pin down because the app is not one process — it's a constellation. This guide covers the mental model, the measurement tools, the leak patterns I see most often in production apps, and a repeatable workflow for hunting them down.

1. Know the process topology first

Before you debug anything, you have to know who is holding what. A typical Electron app runs at least these processes:

Process What it does Where leaks hide
Main (Node.js) app lifecycle, windows, IPC, native modules listeners never removed, BrowserWindow references kept alive
Renderer (Chromium) your UI (one per window / webContents) detached DOM, closures, timers, contextBridge callbacks
GPU compositing and rasterization usually Chromium's business — check for driver bugs, not yours
Utility network service, audio, etc. rarely yours; check version-specific Chromium issues

The single most useful API is process.getProcessMemoryU sage() (main process) and, in the renderer, process.getProcessMemoryInfo(). But the fastest way to see the whole picture is the built-in Task Manager:

// main process
const { app } = require('electron')

app.on('ready', () => {
  const usage = process.getProcessMemoryUsage()
  console.log('main process RSS:', usage.rss)
  console.log('main process heap:', usage.heapUsed, '/', usage.heapTotal)
})
Enter fullscreen mode Exit fullscreen mode
// renderer — remember: still Node-land, but it's the page's process
const info = process.getProcessMemoryInfo()
// { private, shared, workingSetSize, peakWorkingSetSize } — Windows-specific
Enter fullscreen mode Exit fullscreen mode

Rule #1: never estimate. Chrome's DevTools Memory tab and the app's Task Manager disagree; the OS reports differently again. Pick one measurement tool and record baselines per process, per OS.

2. The leak patterns that actually show up in Electron

After auditing dozens of desktop apps, the leaks that survive into production are almost never exotic V8 tricks. They're these:

2.1 IPC listeners that are never removed

This is the number one Electron-specific leak. Every ipcMain.on(...) registration lives for the lifetime of the main process. If you register a handler for every window, every dialog, or inside a re-created module, the old ones pile up — and each one keeps its closure's scope alive.

// BAD — registered per-window, never removed
function wireWindow(windowId) {
  ipcMain.on(`dialog:${windowId}:open`, handler)  // grows forever
}

// GOOD —  one handler, route by event payload
ipcMain.on('dialog:open', (event, payload) => {
  const win = BrowserWindow.fromWebContents(event.sender)
  // ... use payload.windowId if you must
})
Enter fullscreen mode Exit fullscreen mode

The same rule applies to webContents.on('did-finish-load'), powerMonitor.on(...), and anything registered with on() instead of once() — if you register it repeatedly, remove it or make it idempotent.

2.2 Renderer listeners and the "leftover page" problem

In the renderer, the usual suspects are global window.addEventListener, setInterval created in a module that gets re-executed, and observers that never disconnect:

// BAD — module-level interval, every re-import adds another
setInterval(pollServer, 5000)

// GOOD — track and clean up on page hide / unload
const timer = setInterval(pollServer, 5000)
window.addEventListener('beforeunload', () => clearInterval(timer), { once: true })
Enter fullscreen mode Exit fullscreen mode

If you're using contextBridge, be extra careful: every function you expose across the bridge that captures a large scope (a store, a DOM reference, a WebSocket) pins that scope to the isolated world for as long as the page lives.

2.3 Keeping BrowserWindow references after close

A window that is closed but still referenced from an array, a global store, or an event emitter will not be garbage-collected — Chromium keeps the whole webContents alive:

// BAD
const windows = new Map()
windows.set(win.id, win)
win.on('closed', () => { /* forget to delete */ })

// GOOD
w in.on('closed', () => {
  windows.delete(win.id)
  win.webContents = null // let the renderer's world go
})
Enter fullscreen mode Exit fullscreen mode

2.4 Detached DOM nodes

Classic web leak, worse in Electron because pages live longer. Remove nodes from the DOM and drop references; if you keep a Map of "active component instances" keyed by element, you're pinning subtrees.

2.5 Native modules and ffi / koffi handles

Every native binding you create (database, serial, tray icons, hooks) can pin a whole heap if the handle isn't released. Check for close(), destroy(), and reference-counted APIs in your native modules — Electron's nativeImage and Tray instances also hold OS-level resources and should be destroy()ed when no longer needed.

3. A measurement workflow that finds the real leak

Here's the loop I use when someone reports "memory keeps growing":

  1. Snapshot a baseline. Start the app fresh, open the three typical windows, wait 60s, record per-process RSS and renderer heap from the Task Manager + DevTools.
  2. Reproduce the leak deterministically. Open/close a window 30 times. Run the same IPC flow 200 times. Scroll a long list 100 times. If memory doesn't grow, you don't have a repro.
  3. Take heap snapshots. In the renderer DevTools, take snapshot A, run the loop, snapshot B, then use the comparison view. Filter by "Detached" and by "closure" — those are your leaks.
  4. For the main process, use --trace-gc and appmetrics, or simply instrument with a periodic probe:
setInterval(() => {
  const m = process.getProcessMemoryUsage()
  log.push({ t: Date.now(), rss: m.rss, heapUsed: m.heapUsed })
}, 30000)
Enter fullscreen mode Exit fullscreen mode
  1. Bisect. Disable halves of your code (feature flags make this easy) until growth stops. The leak is in the last half you disabled.

One tool worth knowing: run Electron with ELECTRON_ENABLE_LOGGING and, for the renderer, DevTools' Memory > Allocation instrumentation on timeline — it records exactly which allocations are still alive after a GC.

4. Fixes that compound

Most fixes are boring, and that's the point:

  • One IPC surface per channel. Register handlers once in app.whenReady, not per window.
  • Prefer once() over on() where the intent is one-shot.
  • Clear intervals/timeouts on navigation and beforeunload.
  • Close and null windows deterministically. Let 'closed' be the only place that drops the reference.
  • Bound the cache. If you cache remote data, cap size and use a TTL — unbounded caches are the second most common leak after listeners.
  • Test with a soak script. Run your app headless (app.commandLine.appendSwitch('headless') is not needed — just drive it with Playwright's Electron support) and assert memory is flat over an hour.

5. When it's not your leak

Some memory growth is Chromium/Electron itself: GPU process instability, DevTools kept open in production builds, app.disableHardwareAcceleration() changing the balance, or known Chromium bugs. Check p rocess.versions.chrome against known issues, and test with a trivial app (Electron Fiddle) to see whether the same usage pattern leaks without your code. If it does, it's upstream — log it, upgrade Electron, move on.

Checklist — run this before you ship

  • [ ] IPC handlers registered once, no per-window duplication
  • [ ] All on() listeners have a pairing removeListener path
  • [ ] Timers cleaned on unload; no module-level re-created intervals
  • [ ] BrowserWindow references dropped in 'closed'
  • [ ] No detached DOM / no element-keyed instance maps
  • [ ] Caches size-bounded with TTL
  • [ ] 60-minute soak test: per-process RSS is flat ±10%
  • [ ] Baseline recorded per OS in your release notes

Memory leaks are a tax you pay on every user's machine, every day, for the life of the app. The good news: in Electron they're almost always pattern-based, not one mysterious allocation. Measure with one tool, repro deterministically, bisect with feature flags, and fix the boring things — you'll catch 90% of leaks before your users ever do.

Top comments (0)