Steven's Knowledge

Performance

What makes an Electron app responsive — startup time, V8 code cache, memory per window, IPC cost, bundle size, and measurement tools

Performance

Electron ships a full Chromium and a Node.js runtime, so a naive app launches slowly and idles heavy. Good performance is mostly about three things: launching fast, keeping the single main process unblocked, and not paying for memory and bytes you don't use.

Startup time

Time-to-first-paint is what users feel as "fast" or "slow." The two big levers are doing less work before the window shows, and not flashing an empty white window while you do it.

// main.ts — create the window hidden, reveal it only when painted
import { app, BrowserWindow } from 'electron'

function createWindow() {
  const win = new BrowserWindow({
    show: false, // don't show an empty frame
    webPreferences: { sandbox: true, contextIsolation: true },
  })
  win.loadFile('index.html')

  // 'ready-to-show' fires after the first paint — no white flash
  win.once('ready-to-show', () => win.show())
  return win
}

app.whenReady().then(createWindow)

Principles:

  • Defer non-critical work. Telemetry init, auto-update checks, background indexing — schedule them after the window is shown, not during app.whenReady(). The main process is the critical path to first paint.
  • No heavy synchronous work in main during ready. A synchronous fs.readFileSync of a large file, a sync DB migration, or a blocking network call all delay the first window. Make them async or move them off the main thread.
  • Lazy-create windows. Don't construct every window at launch. Create secondary windows (settings, about) on first use.
  • Code-split the renderer. It's a normal web app — route-level dynamic import() and lazy components keep the initial bundle small so the renderer parses and runs less JS up front.
  • Preload only what's needed. The preload script runs before the page; keep it tiny. Heavy logic belongs in main (behind IPC), not in preload.

V8 code cache and compilation cost

Parsing and compiling JavaScript costs real time at startup, and it repeats on every launch unless the compiled output is cached. Modern Electron stores V8's compiled bytecode in an HTTP-style code cache for resources it loads, so a warm second launch skips most recompilation.

The old v8-compile-cache package — which cached compiled CommonJS modules to disk — is largely superseded by Electron's built-in code cache and by bundling. The practical wins today:

  • Bundle and minify the renderer so V8 parses fewer, larger files instead of many small modules.
  • For advanced cases, Electron supports building a V8 startup snapshot of your app's initialization state with electron/js2c-style tooling, so expensive setup runs once at build time. This is powerful but adds build complexity — reach for it only when profiling shows JS init is your bottleneck.

Measure before you optimize startup. "Startup is slow" usually means one specific thing — a blocking call in ready, an oversized renderer bundle, or an antivirus scanning your unsigned binary. Profile with --inspect-brk on main and the DevTools Performance tab on the renderer before adding snapshot tooling you may not need.

Memory

Every BrowserWindow is backed by a full Chromium renderer process. Even an empty one costs roughly tens of MB of baseline memory; a real UI with a framework costs more. A 10-window app is effectively running 10 browser tabs' worth of processes.

Strategies to keep the footprint down:

StrategyWhen to use
Reuse a hidden windowFrequently shown/hidden UI (a quick-note panel). Hide instead of destroy, show instantly.
WebContentsView for sub-viewsEmbed multiple web views inside one window instead of spawning many BrowserWindows. One window process, many views.
Destroy windows you don't needA one-off dialog should be closed and dereferenced so its renderer process exits and reclaims memory.
Watch for leaksRemoved ipcRenderer/ipcMain listeners on unmount; detached DOM nodes held by closures; growing caches with no eviction.
// Prefer WebContentsView over multiple windows for embedded panels
import { BaseWindow, WebContentsView } from 'electron'

const win = new BaseWindow({ width: 1200, height: 800 })
const sidebar = new WebContentsView()
const main = new WebContentsView()
win.contentView.addChildView(sidebar)
win.contentView.addChildView(main)
sidebar.webContents.loadFile('sidebar.html')
main.webContents.loadFile('main.html')

A classic leak: registering ipcRenderer.on(...) in a component and never removing it. Each mount adds a listener; the old DOM and its closures can't be collected. Always return a teardown that calls removeListener.

Keep the main process unblocked

The main process is single-threaded and shared by every window. Anything that blocks it freezes the whole app — all windows stop responding to IPC and OS events. This is the most common cause of an Electron app that "hangs."

Rules:

  • Never use sync IPC (ipcRenderer.sendSync). It blocks the renderer and serializes through main. Use ipcRenderer.invoke / ipcMain.handle (async, promise-based).
  • Never do sync fs or blocking CPU work on the main thread. No readFileSync of large files, no synchronous hashing, no JSON-parsing a 50 MB file inline.
  • Offload heavy work to utilityProcess (a sandboxed Node child, ideal for CPU/IO that needs Node) or worker_threads. Communicate over MessagePort so the main process only orchestrates.
  • Batch IPC instead of chatty per-item calls — see below.
// main.ts — run a CPU-heavy job in a utility process, not on main
import { utilityProcess, MessageChannelMain } from 'electron'
import path from 'node:path'

function runIndexer(files: string[]): Promise<number> {
  return new Promise((resolve) => {
    const child = utilityProcess.fork(path.join(__dirname, 'indexer.js'))
    const { port1, port2 } = new MessageChannelMain()
    child.postMessage({ files }, [port1])
    port2.on('message', (e) => {
      resolve(e.data.count)
      child.kill()
    })
    port2.start()
  })
}

IPC cost

IPC arguments are serialized with the structured clone algorithm and copied between processes. Small messages are cheap; large or frequent ones are not.

  • Don't send whole datasets on every keystroke. Debounce, send deltas, or query on demand. A search box that ships the entire result set per keypress will jank.
  • Transfer large binary with MessagePort transferables, not as a giant base64/JSON blob. An ArrayBuffer can be transferred (ownership moves, no copy) rather than cloned.
  • For very large payloads, use a shared file (write once, pass the path) instead of pushing megabytes through IPC.
  • Batch many small operations into one call. saveAll(items) beats calling save(item) 500 times — each call has fixed serialization and round-trip overhead.
// Transfer an ArrayBuffer instead of copying it
const buf = new Uint8Array(bigData).buffer
port.postMessage({ buf }, [buf]) // [buf] = transfer list; zero-copy

Renderer performance

The renderer is a web page, so the usual web rules apply: virtualize long lists, avoid layout thrash (batch DOM reads then writes), keep components from re-rendering needlessly, and offload expensive computation to a Web Worker. These are the same techniques as any React/web app — the difference in Electron is only that a janky renderer also competes with the main process for the user's perception of responsiveness.

The renderer perf playbook is the web perf playbook. List virtualization, memoization, debouncing, and requestAnimationFrame-batched DOM writes transfer directly. If your renderer is slow, profile it exactly as you would a website — DevTools Performance tab, flame charts, and the React Profiler.

Bundle and install size

Electron apps are large because they bundle Chromium, but your app code and dependencies pile on top. Keep them lean:

  • Tree-shake and minify the renderer and main bundles (esbuild/Vite/webpack in production mode).
  • Prune devDependencies from the packaged app. Use a two-package.json structure (deps for the app, devDeps at the repo root) or electron-builder's files filter so test frameworks and build tools never ship.
  • Pack into asar to bundle many small files into one archive — faster reads and a tidier install.
  • Don't ship source maps in production (or ship them separately to your error tracker, not inside the app).
// electron-builder: ship only what the app needs at runtime
{
  "build": {
    "asar": true,
    "files": [
      "dist/**/*",
      "!**/*.map",
      "!**/test/**",
      "!node_modules/**/{README.md,*.d.ts,*.test.js}"
    ]
  }
}

Background and idle behavior

A backgrounded app should be nearly free.

  • backgroundThrottling is on by default — Chromium throttles timers and rendering in hidden windows to save CPU and battery. Leave it on unless a hidden window must keep doing real-time work (e.g., audio), and even then scope the exception narrowly.
  • Power and efficiency: avoid busy setInterval polling; prefer event-driven updates. On macOS, frequent wakeups hurt battery and show up in Activity Monitor's "Energy" column.
  • GPU flags via app.commandLine (e.g., disabling GPU acceleration) should be used only to work around a specific driver bug, never as a blanket "fix" — they can make things slower.

Measurement

Optimize against data, not hunches. Electron exposes per-process metrics, and Chromium's tooling covers the renderer.

ProblemToolFix
Slow startup--inspect-brk on main; DevTools Performance (renderer)Defer work, code-split, shrink preload
High memoryapp.getAppMetrics(), process.getProcessMemoryInfo()Reuse/destroy windows, use WebContentsView, fix leaks
Main process hangs--inspect on main, flame chartMove sync/CPU work to utilityProcess/workers
Renderer jankDevTools Performance + Memory tabsVirtualize lists, memoize, fix layout thrash
Listener/DOM leaksDevTools Memory → heap snapshots, detached nodesRemove listeners on unmount, drop references
Large IPCLog payload sizes; structured-clone profilingBatch, debounce, transfer buffers, use files
// main.ts — quick runtime memory/cpu snapshot across all processes
import { app } from 'electron'

setInterval(() => {
  for (const m of app.getAppMetrics()) {
    console.log(
      `${m.type} pid=${m.pid} cpu=${m.cpu.percentCPUUsage.toFixed(1)}% ` +
      `mem=${Math.round((m.memory.workingSetSize ?? 0) / 1024)}MB`,
    )
  }
}, 5000)

Profile in a production-like build. Dev mode runs unminified code, source maps, and hot-reload machinery — its numbers don't match what users get. Measure startup, memory, and IPC on a packaged build (asar, minified, fuses applied) before drawing conclusions or shipping an "optimization."

On this page