Electron — Desktop Applications Reference

Building, hardening and shipping a desktop application with Electron: the process model, the preload boundary, IPC, packaging, updates and the diagnostics that matter once the app is on someone else's machine.

Scope: Electron 31 · Chromium + Node · TypeScript · electron-vite · electron-builder · electron-updater · Playwright

01What Electron is, and the process model that follows

Electron bundles Chromium for rendering and Node.js for the system layer, then runs your application as two kinds of process: a single main process that owns windows and OS integration, and one renderer per window that runs the web page.

Almost every Electron mistake comes from forgetting that boundary — doing I/O in the renderer, or blocking the main process with synchronous work.

  • The main process is single-threaded: a 200 ms computation there freezes every window.
  • Renderers are Chromium tabs with all their strengths and memory costs.
  • Assume the renderer is untrusted; treat it like a browser tab that happens to ship with your app.
main process                    renderer (per window)
------------------------------  ------------------------------
app, BrowserWindow, Menu        DOM, CSS, React/Angular
dialog, tray, notifications     fetch(), workers
fs, child_process, ipcMain      ipcRenderer (through preload)
                                
nodeIntegration: false          -> renderer has NO Node access
contextIsolation: true          -> isolated JS world for the page

02Project setup and build pipeline

A modern setup compiles the main process and the renderer separately: the main process is Node-targeted code, the renderer is bundled web code. Electron Forge or electron-vite wire that split for you.

TypeScript across both sides is the sensible default; the IPC contract is where types pay for themselves.

  • Three bundles: main, preload and renderer. Mixing them is the usual cause of 'require is not defined'.
  • Pin the Electron major version; Chromium upgrades change rendering behaviour.
  • Keep the preload bundle tiny — it runs in the critical path of every window.
// package.json
{
  "main": "dist/main/index.js",
  "scripts": {
    "dev": "electron-vite dev",
    "build": "electron-vite build",
    "package": "electron-builder --dir",
    "dist": "electron-vite build && electron-builder"
  },
  "devDependencies": {
    "electron": "^31.0.0",
    "electron-vite": "^2.3.0",
    "electron-builder": "^24.13.0"
  }
}

// electron.vite.config.ts
export default defineConfig({
  main:    { build: { outDir: 'dist/main' } },
  preload: { build: { outDir: 'dist/preload' } },
  renderer:{ build: { outDir: 'dist/renderer' } }
});

03Application lifecycle and window management

`app.whenReady()` is the only safe place to create windows. The cross-platform details that get forgotten: quit on non-macOS when all windows close, and re-create a window on macOS when the dock icon is clicked.

The single-instance lock prevents two copies of the app fighting over the same files.

  • `ready` fires once per app; windows can be created and closed many times.
  • Persist window bounds on close and restore them, or users will resize the app every launch.
  • Do not do heavy work in `whenReady` before creating the window — the user sees nothing.
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) { app.quit(); }
else {
  app.on('second-instance', () => {
    const win = BrowserWindow.getAllWindows()[0];
    if (win) { if (win.isMinimized()) { win.restore(); } win.focus(); }
  });

  app.whenReady().then(() => {
    createWindow();                     // new BrowserWindow({ ... })
    app.on('activate', () => {          // macOS dock click
      if (BrowserWindow.getAllWindows().length === 0) { createWindow(); }
    });
  });

  app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') { app.quit(); }
  });
}

04Preload scripts and contextBridge: the only sane API surface

The preload runs before the page loads, with access to a limited Node API, and injects an object into the page through `contextBridge`. That object is your application's API — nothing else should be reachable from the renderer.

Expose verbs, not primitives: `saveDocument(doc)` rather than `writeFile(path, data)`.

  • Never expose `ipcRenderer` itself — that hands the renderer every channel you own.
  • Return an unsubscribe function from listener registrations, or you leak handlers per mount.
  • Declare the shape once in a `.d.ts` so the renderer gets autocomplete and type checking.
// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';

const api = {
  loadSettings: () => ipcRenderer.invoke('settings:load'),
  saveSettings: (s: Settings) => ipcRenderer.invoke('settings:save', s),
  onSyncStatus: (cb: (s: SyncStatus) => void) => {
    const listener = (_e: unknown, s: SyncStatus) => cb(s);
    ipcRenderer.on('sync:status', listener);
    return () => ipcRenderer.removeListener('sync:status', listener);
  }
};

contextBridge.exposeInMainWorld('desktop', api);

// renderer: window.desktop.saveSettings({ theme: 'dark' })

05IPC: invoke/handle for questions, send/on for events

`ipcRenderer.invoke` paired with `ipcMain.handle` is a promise-based request/response channel. `send`/`on` is fire-and-forget, for progress and state pushes.

The main process must validate everything it receives: the renderer is the untrusted side.

  • Handlers are keyed by channel name, and any renderer can call any channel it knows — validate, do not trust.
  • `event.sender` targets the calling window; broadcasting to all windows is usually a bug.
  • Reject early with a typed error the renderer can map to a message.
// main/ipc.ts
ipcMain.handle('files:open', async (_event, args: unknown) => {
  const { filters } = OpenDialogSchema.parse(args);      // zod validation
  const result = await dialog.showOpenDialog({ properties: ['openFile'], filters });
  if (result.canceled || result.filePaths.length === 0) { return null; }
  return fs.promises.readFile(result.filePaths[0], 'utf8');
});

// long job with progress
ipcMain.handle('export:run', async (event, jobId: string) => {
  for (let pct = 0; pct <= 100; pct += 10) {
    await work(pct);
    event.sender.send('export:progress', { jobId, pct });   // push to that window
  }
  return { ok: true };
});

06Security hardening checklist

Electron's own guidance is short and specific. What reviewers check: isolation flags, a Content Security Policy, blocked navigation, and external links opened in the real browser.

The renderer must never be able to load remote code with Node privileges.

  • `contextIsolation: false` or `nodeIntegration: true` is an automatic finding in a security review.
  • `shell.openExternal` on an unvalidated URL can execute a local handler — allow-list schemes.
  • Disable `webviewTag` unless you genuinely need it; it widens the attack surface.
const win = new BrowserWindow({
  webPreferences: {
    preload: path.join(__dirname, '../preload/index.js'),
    contextIsolation: true,     // default true; never turn it off
    nodeIntegration: false,     // renderer gets no require()
    sandbox: true,              // renderer in the OS sandbox
    webSecurity: true
  }
});

// Only your own origin may navigate or open windows
win.webContents.on('will-navigate', (e, url) => {
  if (new URL(url).origin !== APP_ORIGIN) { e.preventDefault(); }
});
win.webContents.setWindowOpenHandler(({ url }) => {
  shell.openExternal(url);
  return { action: 'deny' };
});

// CSP delivered with the document (index.html meta or headers)
// default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'

07Native capabilities: files, dialogs, tray, notifications

The reason to build a desktop app: real files, real windows, real OS integration. All of it lives in the main process and is offered to the renderer through your preload API.

  • `app.getPath` is per-OS and per-user; hard-coded paths break the first time you ship to another machine.
  • Notifications on Windows need an AppUserModelID to show the app name.
  • Menus and accelerators live in the main process, not in the renderer.
// Tray + notification
const tray = new Tray(path.join(__dirname, 'icon.png'));
tray.setToolTip('Orders desktop');
tray.on('click', () => mainWindow.show());
tray.setContextMenu(Menu.buildFromTemplate([
  { label: 'Open', click: () => mainWindow.show() },
  { label: 'Quit', role: 'quit' }
]));

new Notification({
  title: 'Sync finished',
  body: `${count} orders imported`
}).show();

// Always resolve paths through app.getPath
const logsDir = app.getPath('logs');    // userData, downloads, documents...

08Packaging and distribution

`electron-builder` (or Forge's makers) produces installers per platform. The three things that decide whether users can install it: target format, code signing, and not shipping `node_modules` twice.

`asar` bundles your code into one archive; native modules must be unpacked.

  • Unsigned Windows installers trigger SmartScreen; unsigned macOS builds are blocked outright.
  • Ship the same artifact you tested — no rebuild between QA and release.
  • Keep `dist/**` plus `package.json` only; a stray `node_modules` copy multiplies the installer size.
// electron-builder.yml
appId: com.example.orders
productName: Orders
directories: { output: release, buildResources: build }
files:
  - dist/**
  - package.json
asar: true
asarUnpack:
  - "**/*.node"
win:
  target: [nsis]
  signingHashAlgorithms: [sha256]
mac:
  target: [dmg, zip]
  hardenedRuntime: true
  entitlements: build/entitlements.mac.plist
linux:
  target: [AppImage, deb]

09Auto-update without breaking installations

`electron-updater` reads a feed, downloads in the background and applies on restart. The operational details that matter: channels, staged rollout, and a signature the app can verify.

An update that fails validation should leave the old version running, not a half-installed app.

  • Updates only work on signed, packaged builds — the dev run silently does nothing.
  • A rollback plan is part of the feature: keep the previous installer reachable.
  • Version numbers must increase monotonically per channel, or clients ignore the update.
import { autoUpdater } from 'electron-updater';

autoUpdater.channel = app.isPackaged ? 'stable' : 'beta';
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;

autoUpdater.on('update-available', i => log.info('update available', i.version));
autoUpdater.on('error', e => log.error('update failed', e));

app.whenReady().then(async () => {
  if (!app.isPackaged) { return; }        // never update in development
  await autoUpdater.checkForUpdatesAndNotify();
});

// Never auto-restart while the user has unsaved work: prompt instead

10Performance: startup, memory and the main thread

Electron's costs are Chromium's costs: tens of MB per window and a measurable cold start. The levers that actually move the numbers are fewer windows, deferred work and no synchronous IPC.

`ipcRenderer.sendSync` blocks the renderer until the main process answers — treat it as forbidden in normal flows.

  • One window for a multi-document UI saves far more memory than any renderer optimisation.
  • Virtualise long lists in the renderer; a thousand DOM rows costs more than the IPC call.
  • Profile the main process separately with `--inspect` — DevTools only sees the renderer.
// 1. Create the window early, show it late
const win = new BrowserWindow({ show: false, backgroundColor: '#ffffff' });
win.once('ready-to-show', () => win.show());

// 2. Defer non-critical initialisation
app.whenReady().then(async () => {
  createWindow();
  setTimeout(() => warmUpCaches(), 3000);   // after the UI is visible
});

// 3. Keep payloads small across the bridge
//    structured clone on every IPC call: send ids, not whole collections

// 4. Measure instead of guessing
//    app.commandLine.appendSwitch('enable-logging')
//    DevTools > Performance, and `--inspect` for the main process

11Testing a desktop app

Three layers: unit tests for pure logic, end-to-end tests that drive the packaged app, and a smoke test that the installer actually launches.

Playwright has first-class Electron support through `_electron.launch`, which is the pragmatic choice today.

  • Drive the app the way a user would; testing internal IPC calls directly hides integration bugs.
  • Run the suite on all three platforms in CI if you ship to all three — path and menu behaviour differ.
  • Include one test against the packaged build, not just the dev run.
import { test, expect, _electron as electron } from '@playwright/test';

test('opens a document and shows it in the editor', async () => {
  const app = await electron.launch({ args: ['.'] });
  const window = await app.firstWindow();

  await window.getByRole('button', { name: 'Open file' }).click();
  await expect(window.getByRole('textbox')).toHaveValue(/invoice/);

  const title = await app.evaluate(({ app }) => app.getVersion());
  expect(title).toBe('1.4.0');
  await app.close();
});

// Unit level: the preload API surface, with ipcRenderer mocked
vi.mock('electron', () => ({ ipcRenderer: { invoke: vi.fn() } }));

12Diagnosing problems in the field

A desktop app has no server logs. `crashReporter` plus a rotating log file is the minimum; a source map upload is what makes a stack trace readable.

Ask for the log path in the bug report instead of guessing.

  • Log the app version, OS and Electron/Chromium versions at startup.
  • `render-process-gone` catches renderer crashes that would otherwise look like a blank window.
  • Never log tokens or file contents; desktop logs get attached to tickets.
import { crashReporter } from 'electron';
import log from 'electron-log';

crashReporter.start({ productName: 'Orders', uploadToServer: false });
log.transports.file.resolvePathFn = () => path.join(app.getPath('logs'), 'main.log');
log.transports.file.maxSize = 5 * 1024 * 1024;

process.on('uncaughtException', e => log.error('uncaught', e));

app.on('render-process-gone', (_e, _wc, details) =>
  log.error('renderer gone', details.reason));

// Ship source maps separately so `frame` maps back to your TypeScript

13Electron against the alternatives

Electron is the right answer when the team already writes web UI and needs deep OS access on three platforms. It is the wrong answer when the app is a thin form over a database, or when installer size and memory are the product constraint.

Being able to say when not to use it is what separates experience from familiarity.

OptionChoose it when
ElectronWeb skills, one codebase for Win/macOS/Linux, OS integration, frequent releases
TauriSame web UI model but a Rust core; much smaller binaries, still maturing ecosystem
WPF / WinUI (.NET)Windows-only, deep Windows integration, .NET team already in place
.NET MAUIMostly-forms mobile+desktop app with a shared C# codebase
PWANo installer needed, offline light, and the browser sandbox is acceptable
  • Name the trade-off you accepted, not just the framework you picked.
  • Migration cost is mostly in the OS layer, not the UI: keep that layer isolated either way.
  • Interviewers listen for judgement here more than for API knowledge.
// Decision in three questions:
// 1. Does it need file system, devices or long-running local work?  no  -> PWA
// 2. Is it Windows-only with a .NET team?                            yes -> WPF/WinUI
// 3. Is binary size or memory the hard constraint?                    yes -> Tauri
// Otherwise: Electron, and reuse the web team's stack.
← Back to the knowledge base