Steven's Knowledge

Best Practices

Production Electron engineering — project structure, typed preload contracts, typed IPC, window lifecycle, single-instance lock, config and logging

Best Practices

Production-grade patterns for Electron apps. Guiding principle: the main process owns privilege, the renderer is untrusted, and the preload is the only bridge — and it must be narrow. Everything below follows from treating that boundary as non-negotiable.

Project Structure

Keep main, preload, and renderer code in separate source trees. They run in different runtimes, target different module systems, and are bundled by different tools — mixing them invites accidental imports of Node APIs into the renderer.

src/
├── main/                   Node runtime — app lifecycle, windows, native
│   ├── index.ts            app entry: app.whenReady, window creation
│   ├── windows/            BrowserWindow factories, bounds persistence
│   ├── ipc/                ipcMain.handle registrations
│   └── services/           fs, DB, OS APIs, network — privileged code
├── preload/                Isolated bridge — runs before page scripts
│   └── index.ts            contextBridge.exposeInMainWorld(...)
├── renderer/               Chromium — your UI (React/Vue/Svelte)
│   ├── main.tsx
│   └── app/
├── shared/                 Types shared across all three (no runtime deps)
│   ├── ipc-contract.ts     channel names + payload/response types
│   └── api.ts              the Window['api'] interface
└── resources/              icons, tray assets, static files

shared/ holds types only (and pure constants). It must never import from main/ or pull in Node — both the renderer bundle and the main bundle import it, so anything with a runtime dependency leaks across the boundary.

Build tooling

The main and renderer are bundled by different tools because they target different environments. The renderer is a browser bundle (DOM, ESM, your framework). The main and preload are Node/CommonJS-ish targets that must externalize native modules.

ToolWhat it doesWhen to use
electron-viteOne config, three builds (main/preload/renderer) with HMR for the renderer and reload for main/preloadDefault choice — least config, correct externals out of the box
Vite + electron-builderVite builds the renderer; you wire main/preload builds and electron-builder packagesWhen you want full control of the build graph
Webpack (Electron Forge)Forge's webpack pluginExisting Forge projects

Externalize native and Electron built-ins in the main build. The main/preload bundles must mark electron and any native .node modules as external — they are resolved at runtime, not bundled. electron-vite does this automatically; a hand-rolled config must set build.rollupOptions.external.

The Preload Contract

Define the exposed API as a TypeScript interface in shared/, implement it in the preload, and augment Window so the renderer is fully typed. One source of truth keeps preload and renderer from drifting.

// shared/api.ts
export interface AppConfig {
  theme: 'light' | 'dark';
  telemetry: boolean;
}

export interface AppApi {
  readConfig(): Promise<AppConfig>;
  writeConfig(next: AppConfig): Promise<void>;
  onConfigChanged(cb: (cfg: AppConfig) => void): () => void; // returns unsubscribe
}

// global augmentation — renderer code now sees window.api typed
declare global {
  interface Window {
    api: AppApi;
  }
}
// preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import { CHANNELS } from '../shared/ipc-contract';
import type { AppApi, AppConfig } from '../shared/api';

const api: AppApi = {
  readConfig: () => ipcRenderer.invoke(CHANNELS.readConfig),
  writeConfig: (next) => ipcRenderer.invoke(CHANNELS.writeConfig, next),
  onConfigChanged: (cb) => {
    const listener = (_e: unknown, cfg: AppConfig) => cb(cfg);
    ipcRenderer.on(CHANNELS.configChanged, listener);
    return () => ipcRenderer.removeListener(CHANNELS.configChanged, listener);
  },
};

contextBridge.exposeInMainWorld('api', api);

Expose functions, not ipcRenderer itself. Never exposeInMainWorld('ipc', ipcRenderer) — that hands the renderer an arbitrary message-sending primitive and defeats the point of a narrow bridge. Keep the surface to the specific operations the UI needs.

Typed IPC

Centralize channel names as constants and prefer invoke/handle for anything request-response. send/on is one-way; using it for a request forces you to correlate a separate reply channel by hand.

// shared/ipc-contract.ts
export const CHANNELS = {
  readConfig: 'config:read',
  writeConfig: 'config:write',
  configChanged: 'config:changed',
} as const;
// main/ipc/config.ts
import { ipcMain } from 'electron';
import { CHANNELS } from '../../shared/ipc-contract';
import type { AppConfig } from '../../shared/api';
import { loadConfig, saveConfig } from '../services/config';

export function registerConfigIpc() {
  ipcMain.handle(CHANNELS.readConfig, async (): Promise<AppConfig> => {
    return loadConfig();
  });

  ipcMain.handle(CHANNELS.writeConfig, async (_e, next: AppConfig) => {
    await saveConfig(next);
  });
}

Validate every IPC payload in the main process. Handlers receive data from a renderer you treat as untrusted. Never pass an incoming string straight into fs, child_process, or a SQL query. Validate the shape (e.g. with zod) and check that file paths resolve under an allowed directory before acting on them.

Window Lifecycle

Get the platform conventions right or the app feels broken to native users.

// main/index.ts
import { app, BrowserWindow } from 'electron';
import { createMainWindow } from './windows/main-window';

app.whenReady().then(() => {
  createMainWindow();

  // macOS: clicking the dock icon with no windows open re-creates one
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
  });
});

// Quit when all windows close — except on macOS, where apps stay alive
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

Avoid the white flash

A newly created BrowserWindow paints a blank white surface before your content loads. Create it hidden and show it only once it is ready to paint.

// main/windows/main-window.ts
import { BrowserWindow, app } from 'electron';
import path from 'node:path';

export function createMainWindow() {
  const win = new BrowserWindow({
    show: false, // do not flash white
    width: 1024,
    height: 768,
    backgroundColor: '#1e1e1e', // matches your app shell
    webPreferences: {
      preload: path.join(__dirname, '../preload/index.js'),
      contextIsolation: true, // default; keep it
      nodeIntegration: false, // default; keep it
      sandbox: true,
    },
  });

  win.once('ready-to-show', () => win.show());

  if (app.isPackaged) {
    win.loadFile(path.join(__dirname, '../renderer/index.html'));
  } else {
    win.loadURL(process.env.ELECTRON_RENDERER_URL!);
  }

  return win;
}

Persist window bounds

Remember size and position across launches. Store bounds in a small JSON file under app.getPath('userData') and restore on creation, clamping to a visible display so a window saved on a now-disconnected monitor still appears.

Single-Instance Lock

Most desktop apps should run as a single instance. Acquire the lock before creating windows; if a second launch happens, focus the existing window and consume the new arguments (deep links, files to open).

const gotLock = app.requestSingleInstanceLock();

if (!gotLock) {
  app.quit();
} else {
  app.on('second-instance', (_event, argv) => {
    const win = BrowserWindow.getAllWindows()[0];
    if (win) {
      if (win.isMinimized()) win.restore();
      win.focus();
    }
    // argv contains the second launch's args — handle deep links / file opens here
  });

  app.whenReady().then(() => createMainWindow());
}

Never Block the Main Process

The main process drives the UI of every window. A synchronous CPU- or IO-heavy operation there freezes the whole app. Move heavy work off-main.

// Bad — blocks the main process, freezes all windows
import { readFileSync } from 'node:fs';
const data = parseHuge(readFileSync(bigPath, 'utf8'));

// Good — offload to a utility process (sandboxed Node child)
import { utilityProcess } from 'electron';
const child = utilityProcess.fork(path.join(__dirname, 'workers/parse.js'));
child.postMessage({ path: bigPath });
child.on('message', (result) => { /* ... */ });

Use utilityProcess (or a worker_threads Worker) for parsing, hashing, image processing, or large file IO. Prefer async fs APIs everywhere; reserve sync variants for tiny reads at startup only.

Configuration and Environment

Externalize anything that varies by environment — API base URLs, feature flags, update channel. Read environment at the main-process boundary and pass concrete values down; do not sprinkle process.env checks across renderer code (the renderer should not see your environment at all).

// main/config/env.ts
export const env = {
  isDev: !app.isPackaged,
  apiBase: process.env.API_BASE ?? 'https://api.example.com',
  updateChannel: process.env.UPDATE_CHANNEL ?? 'stable',
} as const;

Filesystem and App Directories

Never hardcode paths. Use app.getPath for OS-correct, per-user locations, and keep writable data out of the app bundle (which is read-only and may be inside an asar archive).

app.getPath(name)Use for
userDataApp config, databases, caches you own
logsLog files (electron-log uses this by default)
tempScratch files
downloadsDefault save location for user downloads
homeThe user's home directory
import { app } from 'electron';
import path from 'node:path';

const configPath = path.join(app.getPath('userData'), 'config.json');

Logging

Use electron-log instead of console. It writes to a file under logs/, works in both main and renderer, and survives in production where console output is invisible.

// main/index.ts
import log from 'electron-log/main';

log.initialize(); // wires up the renderer transport too
log.transports.file.level = 'info';
log.info('app starting', { version: app.getVersion() });

// always install a last-resort handler in the main process
process.on('uncaughtException', (err) => log.error('uncaught', err));
process.on('unhandledRejection', (reason) => log.error('unhandledRejection', reason));

Tie logs to crash reports. When you also wire up crashReporter / a service like Sentry, log a session id at startup and include it in crash metadata. Correlating a user's log file with a crash event is what turns a vague bug report into a fixable one.

Checklist

  • Separate main/ preload/ renderer/ source trees; shared/ is types-only.
  • One typed AppApi interface; preload exposes functions, never ipcRenderer.
  • invoke/handle for request-response; channel names as constants; validate payloads in main.
  • show: false + ready-to-show; correct activate / window-all-closed behavior.
  • Single-instance lock with second-instance focus + arg handling.
  • Heavy work in utilityProcess/workers; async fs; never block main.
  • app.getPath for writable data; electron-log for diagnostics; handlers for uncaughtException/unhandledRejection.

On this page