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

  1. Start with Measurements, Not Opinions
  2. Startup Path: The Main Process
  3. Renderer: Frames, Not Feelings
  4. IPC Discipline: Fewer Trips, Smaller Payloads
  5. The Quick-Win Checklist
  6. Pitfalls I Hit So You Don't Have To
  7. 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:

  1. Create the window with a background color that matches your UI, so the first paint is not a white flash.
  2. 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:

  1. ✅ Instrument startup with checkpoint marks (30 min)
  2. ✅ Convert top-level require/import of heavy modules to lazy (1–2 h)
  3. ✅ Set window backgroundColor to kill the white flash (5 min)
  4. ✅ Load a local shell first; hydrate content async (1 h)
  5. ✅ Audit renderer: batch DOM writes, remove layout thrash (2–4 h)
  6. ✅ Reduce IPC round trips; add payload validation (2 h)
  7. ✅ Profile with --enable-logging, DevTools Performance panel, and the Performance API in the renderer
  8. ✅ 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.