Electron Performance: From "Why Is It So Slow?" to a Snappy App
A field guide to measuring and fixing Electron startup time, renderer jank, and IPC bottlenecks — with real code you can apply today.
Tags: electron, node, tooling, productivity
Table of Contents
- Start with Measurements, Not Opinions
- Startup Path: The Main Process
- Renderer: Frames, Not Feelings
- IPC Discipline: Fewer Trips, Smaller Payloads
- The Quick-Win Checklist
- Pitfalls I Hit So You Don't Have To
- Conclusion
Electron gets a bad reputation for being slow. Sometimes it deserves it. But in my experience most Electron apps are not slow because Electron is slow — they are slow because nobody measured anything until the app was already 200 MB of dependencies deep. This post is the checklist I now run on every Electron project: measure, then fix, in that order.
1. Start with Measurements, Not Opinions
Before changing a single line, establish three baseline numbers:
| Metric | What it tells you | How to measure |
|---|---|---|
| Time to window | Main process + first BrowserWindow painted | --enable-logging + process.hrtime() at key checkpoints |
| Time to interactive | Renderer finished loading & JS ready | performance.timing / PerformanceObserver in the renderer |
| IPC round-trip p95 | Main↔renderer latency under load | Timestamp every IPC call for a day of usage |
The fastest way to get a startup trace is to instrument the main process with checkpoint timestamps:
// main.js const t0 = process.hrtime.bigint(); const mark = (label) => { const dt = Number(process.hrtime.bigint() - t0) / 1e6; console.log(`[boot] ${label}: ${dt.toFixed(1)}ms`); }; mark("module loaded"); app.whenReady().then(() => { mark("app ready"); createMainWindow(); mark("window created"); }); Run it with:
electron . --enable-logging You will immediately see which require() is eating your startup budget. I have repeatedly found one "innocent" top-level require costing 400–900 ms — usually a database driver, an icon library, or an analytics SDK that should have been lazy-loaded.
Golden rule: the main process should do the minimum work to show a window. Everything else happens after.
2. Startup Path: The Main Process
2.1 Defer every heavy import
Convert eager top-level imports into lazy ones. With ESM you can use dynamic import(); with CommonJS, move the require into the function that uses it:
// BEFORE (eager): costs ~700ms at startup const { Database } = require("./db"); const { Analytics } = require("./analytics"); // AFTER (lazy): near-zero startup cost async function openDatabase() { const { Database } = await import("./db.js"); return new Database(); } For big third-party packages that you genuinely need early, consider loading them in a utility process instead of the main process (see Electron's utilityProcess.fork) and keep the main process lean.
2.2 Create the window after ready, but show something fast
A blank white window feels terrible. Two tricks make it feel instant:
- Create the window with a background color that matches your UI, so the first paint is not a white flash.
- Load a minimal local shell immediately, and populate content asynchronously.
const win = new BrowserWindow({ width: 1200, height: 800, show: false, backgroundColor: "#0f172a", // match your theme, kills the white flash }); win.once("ready-to-show", () => win.show()); win.loadURL("file://" + path.join(__dirname, "shell.html")); 2.3 Trim app.commandLine experiments carefully
Some switches genuinely help:
app.commandLine.appendSwitch("disable-renderer-backgrounding"); app.commandLine.appendSwitch("enable-features", "V8TurboFan"); Most switches do not belong in production. The Electron team tunes the defaults for a reason. Treat every switch you add as debt: benchmark before and after, and document why it is there.
2.4 V8 snapshot and code cache
If your startup trace shows a long pause inside JavaScript parsing, look at V8 snapshot and code cache workflows. A custom V8 snapshot can move hundreds of milliseconds of parse time out of the critical path. This is an advanced lever — only pull it after step 1 says parsing is your bottleneck.
3. Renderer: Frames, Not Feelings
Renderer jank is almost always layout thrash + too many DOM writes per frame, not Chromium being slow.
3.1 Batch DOM writes with requestAnimationFrame
// BEFORE: 20 layout reads/writes interleaved → 20 forced layouts items.forEach((item) => { list.appendChild(renderItem(item)); // write const w = item.offsetWidth; // read → forces layout badge.style.width = w + "px"; // write again }); // AFTER: one read phase, one write phase const widths = items.map((it) => it.offsetWidth); // read once requestAnimationFrame(() => { items.forEach((it, i) => { const el = renderItem(it); el.querySelector(".badge").style.width = widths[i] + "px"; list.appendChild(el); }); }); 3.2 Know what throttling does
Electron throttles requestAnimationFrame and timers in background windows (and hidden windows by default). That is usually correct — but if your window keeps a WebSocket or a sync loop alive, background throttling can make the app appear frozen on re-focus. Audit background behavior explicitly:
win.webContents.setBackgroundThrottling(false); // only when you truly need it 3.3 Use OffscreenCanvas for heavy drawing
Canvas work that does not need to be shown immediately belongs off-screen:
const off = new OffscreenCanvas(w, h); const ctx = off.getContext("2d"); // expensive drawing here... const bitmap = off.transferToImageBitmap(); This keeps the main thread free for input and layout.
4. IPC Discipline: Fewer Trips, Smaller Payloads
Most "IPC is slow" complaints are actually "I made 3,000 round trips per second" complaints. Rules I now enforce:
| Rule | Why |
|---|---|
| Batch list requests into one channel | One round trip vs. N |
| Use structuredClone-safe payloads | Dates, Maps, Buffers survive; class instances don't |
| Keep the hot path in the renderer | If data never leaves the renderer, don't ask the main process for it |
| Prefer MessagePorts for streaming | postMessage over a transferred port avoids a trip per chunk |
Example — batching:
// main ipcMain.handle("files:get-many", async (_e, ids) => { return Promise.all(ids.map(loadOne)); // one round trip for N files }); // renderer const files = await ipcRenderer.invoke("files:get-many", ids); And always validate the payload at the boundary (contextBridge + ipcRenderer.invoke). Untrusted renderer input is a security hole before it is a performance problem.
5. The Quick-Win Checklist
Run this list in order — each item is independently measurable:
- ✅ Instrument startup with checkpoint marks (30 min)
- ✅ Convert top-level
require/importof heavy modules to lazy (1–2 h) - ✅ Set window
backgroundColorto kill the white flash (5 min) - ✅ Load a local shell first; hydrate content async (1 h)
- ✅ Audit renderer: batch DOM writes, remove layout thrash (2–4 h)
- ✅ Reduce IPC round trips; add payload validation (2 h)
- ✅ Profile with
--enable-logging, DevTools Performance panel, and the Performance API in the renderer - ✅ Re-baseline and record the delta in your repo's README
6. Pitfalls I Hit So You Don't Have To
| Pitfall | Symptom | Fix |
|---|---|---|
| Icon library imported at module scope | +400 ms startup for one icon | Lazy-import; or inline the 3 icons you actually use as SVG |
BrowserWindow created before app.whenReady | Crash or race on some platforms | Always create after ready |
| DevTools open in production | Huge renderer memory + slower input | Never open DevTools in production; strip with process.env.NODE_ENV |
| IPC handler that awaits a DB on every keystroke | Type-ahead latency & queue pile-up | Debounce + cache in renderer; batch writes |
| backgroundThrottling left on for a chat window | Incoming messages delayed while minimized | Set it false only for the windows that need it |
| Packaging with all locales | Larger download, slower cold start | Trim locales and unused code paths in electron-builder |
Conclusion
Electron apps are fast when you treat performance as a measured property instead of a vibe. Start the trace, fix the top three offenders, re-measure, and write the numbers down. Most apps find their biggest win in the main-process require() budget, then in renderer layout thrash, then in IPC chatter — in that order.
The tools are all free and built-in: --enable-logging, the Performance panel, process.hrtime, and performance.now(). The discipline is the only thing you have to add.
This is part of a series on Electron development. Previous posts covered packaging/code-signing/auto-update, the process model & IPC, and memory management & leak hunting. Next up: security hardening with CSP, sandboxing and permissions.
