Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

Electron Performance Playbook: Diagnose Slow Startup and Fix Runtime Jank

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 prel

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-load is 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: fs calls 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 a utilityProcess or 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:

  1. 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.
  2. Compositor pressure — too many layers, huge images, CSS filter/backdrop-filter everywhere. Each animated filter forces a repaint of its subtree. Prefer transform/opacity animations (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" />
  1. Memory growth — Electron apps leak through detached DOM nodes, stale ipcRenderer listeners, and never-revoked webContents references.

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 snapshot at day one — powerful, but it's a build-pipeline commitment. Do the free fixes first; snapshots are for when you've exhausted them.
  • Disabling contextIsolation or sandbox for "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.env hacks 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.