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 filesshared/ 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.
| Tool | What it does | When to use |
|---|---|---|
| electron-vite | One config, three builds (main/preload/renderer) with HMR for the renderer and reload for main/preload | Default choice — least config, correct externals out of the box |
| Vite + electron-builder | Vite builds the renderer; you wire main/preload builds and electron-builder packages | When you want full control of the build graph |
| Webpack (Electron Forge) | Forge's webpack plugin | Existing 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 |
|---|---|
userData | App config, databases, caches you own |
logs | Log files (electron-log uses this by default) |
temp | Scratch files |
downloads | Default save location for user downloads |
home | The 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
AppApiinterface; preload exposes functions, neveripcRenderer. invoke/handlefor request-response; channel names as constants; validate payloads in main.show: false+ready-to-show; correctactivate/window-all-closedbehavior.- Single-instance lock with
second-instancefocus + arg handling. - Heavy work in
utilityProcess/workers; async fs; never block main. app.getPathfor writable data;electron-logfor diagnostics; handlers foruncaughtException/unhandledRejection.