Electron Performance Playbook: Diagnose Slow Startup and Fix Runtime Jank
TL;DR — Electron apps usually feel slow for a small set of reproducible reasons: synchronous main-process work, late-ready windows, bloated preload scripts, and renderer code that fights the compositor. This article walks through how to measure each phase, the fixes with the best bang for the buck, and a reference main.js skeleton you can adapt today.
1. Two battlefields: startup and runtime
Performance complaints in Electron fall into two buckets, and they need different tools:
| Battlefield | Symptom | Cause | Fix direction |
|---|---|---|---|
| Startup | Cold launch takes 3–6s, blank window hangs | Main process does heavy work before ready, renderer loads too much too soon | Defer work, show the window at the right moment, lazy-load |
| Runtime | Scrolling stutters, animations drop frames, memory climbs | Blocked event loop, compositor fights, unbounded caches | Keep the main thread free, respect paintWhenInitiallyHidden, cap caches |
The mistake most teams make is guessing. Before touching code, measure — the phases are visible, and the tools are free.
2. Measure first: the startup timeline
Electron gives you the hooks to build a precise timeline. The classic approach is a log of timestamps across the boot path:
// main.js — startup timeline const steps = []; const mark = (name) => steps.push({ name, t: Date.now() - start }); const start = Date.now(); mark('process start'); app.whenReady().then(() => { mark('app ready'); createWindow(); }); app.on('web-contents-created', (_event, contents) => { contents.on('did-start-loading', () => mark('renderer start loading')); contents.on('did-finish-load', () => mark('renderer finished load')); contents.on('did-stop-loading', () => mark('renderer stopped loading')); }); app.on('browser-window-created', (_event, win) => { win.once('ready-to-show', () => { mark('ready-to-show'); console.table(steps); }); }); Three numbers matter:
-
ready-to-show— when the window is actually paintable. This is what users perceive as "launched". - renderer load time — how long your HTML/CSS/JS takes to first paint.
- gap between them — if the renderer finished long before
ready-to-show, you are holding the window hostage to main-process work.
For renderer-side detail, use the built-in Performance API:
// renderer window.addEventListener('load', () => { const nav = performance.getEntriesByType('navigation')[0]; console.log('domContentLoaded', nav.domContentLoadedEventEnd); console.log('first paint (approx)', performance.now()); }); Pitfall #1: measuring
did-finish-loadis not measuring first paint. A heavy font or a blocking<script>can make the gap between "DOM loaded" and "pixels on screen" huge. Watch the window, not just the document.
3. Fix the window: show: false + ready-to-show
The single cheapest, highest-impact startup fix: don't show a blank shell.
const win = new BrowserWindow({ show: false, // no white flash backgroundColor: '#0f172a', // matches your app's background webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); win.once('ready-to-show', () => win.show()); Why it works: by default Electron paints the window as soon as it can, which usually means an empty/white frame for several hundred milliseconds. With show: false, the window appears only when the compositor has something real to draw, and backgroundColor makes the brief moment indistinguishable from your UI.
When NOT to use it: splash screens. If you deliberately want a branded splash, create it as a small, separate frameless window that shows immediately, then swap. Don't block ready on the splash.
4. Defer main-process work
A startup that does file scans, DB connections, and IPC registrations before app.whenReady() is a startup that burns seconds before the window can exist. Most of that work is not needed for the first paint:
app.whenReady().then(() => { // 1. Create the window first — it owns the perceived launch time. createWindow(); // 2. Defer everything else until after the first paint settles. setImmediate(() => { initDatabase(); // async, don't await loadUserPreferences(); registerGlobalShortcuts(); }); }); Better: use idle time explicitly. powerMonitor and setImmediate are blunt; for CPU-sensitive work, requestIdleCallback exists in the main process too (Node 16+), or simply chunk work across ticks:
const jobs = [syncCache, warmIcons, buildMenus]; (function next() { const job = jobs.shift(); if (!job) return; job(); setImmediate(next); // yield the event loop between jobs })(); Pitfall #2:
require()is synchronous and runs at load time. A module that opens a socket or reads config at import time will drag every window into that cost. Lazy-load heavy modules:
let updater; function getUpdater() { if (!updater) updater = require('./updater'); // loaded only on first use return updater; } 5. Fix the renderer: paintWhenInitiallyHidden and friends
In Electron ≥ 21, background renderers are throttled. If you create a window but keep it hidden (e.g. a tray popup or a pre-created window), requestAnimationFrame won't fire and timers get throttled — which can make the window appear to "hang" when it finally shows.
Two flags matter:
new BrowserWindow({ show: false, webPreferences: { backgroundThrottling: true, // default; keep true for hidden windows }, }); For a window that must do work while hidden (music player, notifications, upload queue), use paintWhenInitiallyHidden: true on the ready-to-show gate instead of disabling throttling globally:
const win = new BrowserWindow({ show: false }); win.once('ready-to-show', () => win.show()); // If you truly need rAF while hidden: win.webContents.setBackgroundThrottling(false); Prefer scoped opt-outs. Disabling throttling for all windows is how laptops start melting — a hidden window chewing CPU is invisible and unbounded. Scope it to the one window that legitimately needs it, and hide it when its job is done.
6. Keep the main process event loop free
The number one cause of "Electron is ja nky" is a blocked main process. The renderer has a compositor thread that can mask main-thread stalls; the main process has no such shield. Every synchronous call you make from the renderer crosses the IPC boundary and can stall the entire app.
Dangerous patterns:
// Renderer — blocks main process for the duration const result = ipcRenderer.sendSync('get-settings'); const big = ipcRenderer.sendSync('read-big-file'); Safe patterns:
// Main ipcMain.handle('get-settings', async () => await loadSettings()); // Renderer const settings = await ipcRenderer.invoke('get-settings'); For long-running work, return a channel and stream progress, don't hold a promise:
ipcMain.handle('process-csv', async (event, file) => { const task = new CSVProcessor(file); task.on('progress', (p) => event.sender.send('csv:progress', p)); return task.run(); // never blocks a synchronous loop }); Pitfall #3:
fscalls in the main process are async by default — good. But native modules that wrap synchronous C code (sqlite, bcrypt, sharp in some paths) will still freeze the event loop. If you use one, wrap it in autilityProcessor offload to a worker thread and keep the main process to message passing.
7. The preload is not free
Every byte of your preload script runs in every renderer, before your page scripts, on the critical path of first paint. A preload that requires lodash, builds a huge bridge object, or does DOM queries is a tax on every window, every tab.
Keep it lean:
// preload.js — minimal bridge only const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('app', { getSettings: () => ipcRenderer.invoke('get-settings'), onProgress: (cb) => { const listener = (_e, p) => cb(p); ipcRenderer.on('csv:progress', listener); return () => ipcRenderer.removeListener('csv:progress', listener); }, }); Measure it too — in the timeline above, add a mark inside preload:
// preload.js const t0 = Date.now(); // ...bridge code... console.log('preload took', Date.now() - t0); If it's above a few milliseconds, move non-bridge logic into the page script (deferred) instead.
8. Runtime: protect the compositor
Once the app is open, "jank" usually means dropped frames. Frame drops come from three places:
- Main thread stalls in the renderer — heavy synchronous JS (parsing huge JSON, React reconciliation of a giant tree, layout thrash). Fix: chunk, defer, memoize, virtualize.
- Compositor pressure — too many layers, huge images, CSS
filter/backdrop-filtereverywhere. Each animated filter forces a repaint of its subtree. Prefertransform/opacityanimations (compositor-only) and cap image decode size:
// Renderer: don't ship a 12MB screenshot into a 300px <img> <img loading="lazy" decoding="async" src="thumb.jpg" width="300" /> - Memory growth — Electron apps leak through detached DOM nodes, stale
ipcRendererlisteners, and never-revokedwebContentsreferences.
The listener leak is the most common and most insidious:
// Renderer — this leaks a listener per call function watch(id) { window.api.onUpdate((data) => render(id, data)); } // Correct: return a disposer and use it function watch(id) { const off = window.api.onUpdate((data) => render(id, data)); return off; } // ...and when the view unmounts: off(); Make every subscription return a teardown. Review ipcMain.on handlers the same way — if you register the same handler twice (hot-reload, re-init), you double-process every message.
9. The startup checklist
| # | Optimization | Effort | Perceived win |
|---|---|---|---|
| 1 | show: false + ready-to-show + backgroundColor | 5 min | Large — kills white flash |
| 2 | Create window before heavy init | 10 min | Large — moves ready-to-show earlier |
| 3 | Lazy-require heavy modules | 15 min | Medium — shrinks main-process pre-ready work |
| 4 | Timeline logging | 30 min | — (enables everything else) |
| 5 | Trim preload to a bridge | 20 min | Medium — faster first paint per window |
| 6 | Replace sendSync with invoke/handle | 30 min | Large — unblocks main event loop |
| 7 | Disposable IPC listeners | 20 min | Medium — stops slow memory creep |
| 8 | Compositor-friendly CSS (transform/opacity) | Ongoing | Large on long lists/animations |
10. Reference skeleton (main.js)
const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); app.whenReady().then(() => { createWindow(); setImmediate(() => { require('./lazy/init').default(); // deferred, not blocking }); }); function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, show: false, backgroundColor: '#0f172a', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); win.once('ready-to-show', () => win.show()); win.loadFile('index.html'); } ipcMain.handle('get-settings', () => require('./settings').load()); ipcMain.handle('process-csv', async (event, file) => { const { CSVProcessor } = require('./csv'); const task = new CSVProcessor(file); task.on('progress', (p) => event.sender.send('csv:progress', p)); return task.run(); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); 11. What I'd avoid
-
V8 snapshot/custom snapshotat day one — powerful, but it's a build-pipeline commitment. Do the free fixes first; snapshots are for when you've exhausted them. - Disabling
contextIsolationorsandboxfor "speed" — the perf gain is marginal and you lose the security model entirely. Never optimize security away. - Multiple hidden windows "for speed" — each window is a full renderer process with its own memory. One visible window beats three cached ones.
-
process.envhacks to skip code — if a feature is slow, measure and fix the feature; don't gate it behind an env var you'll forget exists.
The one-liner: Electron's perceived startup is decided in the first 500ms of the main process and the first 200ms of the renderer — measure the phases, show the window only when it's paintable, keep the event loop free, and make every subscription disposable. Do that and your app will feel native without a rewrite.

