Most Electron apps start with one window, and that stays enough for a long time. The moment you add a settings panel, a separate document or project view, a notifications tray window, or a second instance opened from the dock, a single BrowserWindow stops scaling: windows pile up with no owner, state drifts between copies, closed windows linger in memory, and two renderers start reaching for each other directly. A multi-window app is still a main-process-first app — the main process owns every window, mediates every message, and holds the single source of truth. This guide covers how to register and factory-create windows, when to reuse a hidden window versus destroy it, how to isolate renderers and sessions, how to offload heavy work to a utility process, how to coordinate windows through the main process instead of window.opener, and how to release resources when a window closes.
TL;DR
-
The main process owns windows. Renderers never construct or reach into another
BrowserWindow; a smallWindowManagerin the main process creates, tracks, focuses, and closes them. - One window is one renderer process by default. Keeping heavy state and large objects out of shared renderer memory matters more once you multiply windows, because each renderer carries its own heap.
- Reuse panels, destroy documents. Hide and re-enter a settings or palette window; close and dispose a per-document window when its work is done. Never keep a reference to a closed window.
-
Isolate sessions on purpose. Use separate
partitionvalues where one window must not share cookies, cache, or storage; share a partition where the signed-in state should carry across windows. -
Heavy work goes to a utility process, not a second window. A
utilityProcessisolates CPU-heavy or flaky code from the main process and from renderers without the cost of another full UI surface. -
Communicate through the main process. Broadcast and request-response channels live in
ipcMain; renderers subscribe to events rather than importing another window's internals. - The two traps that cost real time: (1) caching a closed window and reopening a dead reference; (2) letting each renderer keep its own copy of global state, so two windows disagree about what the user did.
Table of contents
- When one window stops being enough
- Windows, renderers, and the main process
- A window manager: factory and registry
- Single-instance lock and activating an existing window
- Lifecycle: reuse a hidden window or destroy it
- Process and session isolation
- Offloading work to a utility process
- Coordinating windows through the main process
- State ownership and avoiding drift
- Reclaiming resources and recovering from a crashed renderer
- Common pitfalls
- Checklist
When one window stops being enough
A second window is justified when a piece of UI has a different lifetime, audience, or context than the main view:
| Need | Typical second window | Reuse pattern |
|---|---|---|
| Preferences, themes, account | Settings / preferences window | Hidden and reopened; same instance for the app's lifetime |
| Tools, palette, inspector | Side panel or floating panel | Hidden and reopened; follows the active document optionally |
| Independent document or project | One window per open item | Destroyed when the item closes; registry tracks open items |
| Login, onboarding, auth flow | Modal or standalone auth window | Destroyed after success; never left behind |
| Popover, quick input | Tray popout or small panel | Hidden after use; cheap to recreate |
The dividing line is lifetime and state. A panel that the user toggles often is worth keeping alive; a per-document window that holds a large dataset should be released when it closes.
Windows, renderers, and the main process
By default each BrowserWindow loads its own renderer process, separate from the main process and from other renderers. The main process holds the BrowserWindow object and its webContents; renderers only know about the page they loaded.
| Layer | Owns | Must not do |
|---|---|---|
| Main process | Window lifecycle, registry, IPC routing, persistent state | Block the event loop with heavy synchronous work |
| Renderer A | Its own DOM, UI state, local view data | Import or reach into Renderer B's objects |
| Renderer B | Its own DOM, UI state, local view data | Assume another renderer is still alive |
| Utility process | Isolated background task | Touch UI or BrowserWindow directly |
Because renderers are isolated, the only reliable path between two windows goes through the main process. That constraint is what keeps a multi-window app testable and secure: there is no hidden back channel between pages.
A window manager: factory and registry
Put window creation behind a small manager instead of sprinkling new BrowserWindow(...) across the main process. The manager keeps a registry keyed by an internal window id, exposes factory methods per window kind, and centralizes focus, close, and lookup logic.
// main/window-manager.ts
import { BrowserWindow, ipcMain, type WebContents } from "electron";
type WindowKind = "main" | "settings" | "document";
interface WindowEntry {
win: BrowserWindow;
kind: WindowKind;
key?: string; // e.g. document id for per-item windows
}
export class WindowManager {
private windows = new Map<number, WindowEntry>();
private nextId = 1;
private register(entry: Omit<WindowEntry, "win">, win: BrowserWindow) {
const id = this.nextId++;
this.windows.set(id, { ...entry, win });
win.on("closed", () => {
this.windows.delete(id);
});
return id;
}
private baseOptions() {
return {
width: 1100,
height: 720,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
};
}
openMain(entryUrl: string) {
const existing = this.findByKind("main");
if (existing) {
this.focus(existing);
return existing;
}
const win = new BrowserWindow(this.baseOptions());
this.register({ kind: "main" }, win);
win.loadURL(entryUrl);
win.once("ready-to-show", () => win.show());
return win;
}
openDocument(entryUrl: string, docKey: string) {
const existing = this.findByKey(docKey);
if (existing) {
this.focus(existing);
return existing;
}
const win = new BrowserWindow({ ...this.baseOptions(), width: 900, height: 640 });
this.register({ kind: "document", key: docKey }, win);
win.loadURL(`${entryUrl}?doc=${encodeURIComponent(docKey)}`);
win.once("ready-to-show", () => win.show());
return win;
}
findByKind(kind: WindowKind) {
for (const entry of this.windows.values()) if (entry.kind === kind) return entry.win;
return undefined;
}
private findByKey(key: string) {
for (const entry of this.windows.values()) if (entry.key === key) return entry.win;
return undefined;
}
focus(win: BrowserWindow) {
if (win.isMinimized()) win.restore();
win.focus();
}
allWebContents(): WebContents[] {
return [...this.windows.values()].map((e) => e.win.webContents);
}
}
The registry gives you one place to answer "which windows are open?", to broadcast an event, and to clean up on app quit. Looking up by kind or by document key also prevents duplicate windows for the same item.
Single-instance lock and activating an existing window
When the user launches the app a second time — from the dock, a file association, or a deep link — you usually do not want a second copy of the app. Acquire the single-instance lock early in app.whenReady(). If the lock fails, the second instance should focus the existing one and exit.
// main/index.ts
import { app, ipcMain } from "electron";
import { WindowManager } from "./window-manager";
const wm = new WindowManager();
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", (_event, argv) => {
// Another instance tried to start: bring the existing window forward,
// and hand off any file or deep-link argument.
const main = wm.findByKind("main");
if (main) {
if (main.isMinimized()) main.restore();
main.focus();
}
// argv may carry a file path or deep link to open in the existing instance.
});
app.whenReady(() => {
wm.openMain(process.env.APP_URL ?? "index.html");
});
}
On macOS, closing the last window often leaves the app running in the dock; on Windows and Linux the default quits when all windows close. Decide which behavior you want explicitly instead of inheriting the platform default.
Lifecycle: reuse a hidden window or destroy it
The cheapest window is the one you do not recreate, but keeping every window alive forever accumulates renderer heaps. Choose per kind:
| Strategy | When | What happens on close |
|---|---|---|
| Hide and reuse | Settings, palette, inspector — small state, toggled often |
win.hide() on close; registry keeps the entry; show() on next open |
| Destroy and dispose | Per-document windows, auth flows, large data views |
win.close(); closed event deletes the registry entry; release held data |
| Recreate each time | Ephemeral popovers, one-off dialogs | Close normally; do not cache the window object |
Hiding instead of closing needs an explicit close handler, otherwise the default destroys the window:
// Reusable settings window: intercept close, hide instead of destroy.
settingsWin.on("close", (event) => {
if (!app.isQuitting) {
event.preventDefault();
settingsWin.hide();
}
});
app.on("before-quit", () => {
app.isQuitting = true;
});
Never keep a reference to a window after its closed event has fired. Calling methods on a destroyed BrowserWindow throws, and a stale reference is the most common multi-window memory leak.
Process and session isolation
Each renderer is already a separate process, but sessions and storage can still be shared or split through the partition option:
| Partition choice | Shared | Use for |
|---|---|---|
| Default (no partition) | Cookies, cache, localStorage, session data across windows that load the same session | Main app windows that should stay signed in together |
Persistent partition (persist:name) |
A separate on-disk session, distinct from the default | Workspace/account isolation that survives restart |
In-memory partition (name, no persist:) |
Separate session wiped when the window closes | Guest, preview, or temporary auth windows |
const guestWin = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: "guest-preview", // in-memory; not shared with the main session
},
});
Keep contextIsolation: true, nodeIntegration: false, and sandbox: true on every window. A window that loads less trusted content (a preview, an embedded page) deserves the same hardening as the main one.
Offloading work to a utility process
A second window is not the right tool for a heavy background task. A new renderer still costs a full UI surface and its own heap, and a crash there can take that window down. Use utilityProcess.fork for CPU-heavy parsing, indexing, large-file processing, or flaky native code that should not drag down the main process.
// main/index.ts
import { utilityProcess } from "electron";
function startIndexer() {
const child = utilityProcess.fork(
require.resolve("./workers/indexer"),
[],
{ stdio: "pipe" },
);
child.on("message", (msg) => {
// Forward progress to whichever window requested it.
wm.allWebContents().forEach((wc) => wc.send("indexer:progress", msg));
});
child.on("exit", (code) => {
if (code !== 0) console.error("indexer exited with", code);
});
return child;
}
The utility process has no DOM and no BrowserWindow access; it talks to the main process over messages. Isolate it so a crash or leak there does not take the UI with it, and restart it deterministically if it exits unexpectedly.
Coordinating windows through the main process
Renderers should not import another window's module or rely on window.opener. The main process routes two patterns: broadcast (one event to all windows) and request-response (one renderer asks the main process, which replies or routes to the right window).
// main/window-manager.ts — broadcast to every open window
broadcast(channel: string, payload: unknown) {
for (const entry of this.windows.values()) {
if (!entry.win.isDestroyed()) entry.win.webContents.send(channel, payload);
}
}
// Request from a renderer, answered by the main process.
ipcMain.handle("documents:list", async () => {
return documentStore.list(); // single source of truth lives here
});
On the renderer side, the preload exposes a narrow, typed bridge:
// preload/index.ts
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("desktop", {
listDocuments: () => ipcRenderer.invoke("documents:list"),
onThemeChanged: (cb: (theme: string) => void) =>
ipcRenderer.on("theme:changed", (_e, theme) => cb(theme)),
});
Cross-window state changes — a theme switch, an item added in one document, a sign-out — flow as an event through the main process, and each renderer updates its projection.
State ownership and avoiding drift
The fastest way to make a multi-window app confusing is to let each renderer cache its own copy of global state and write it back independently. Assign ownership explicitly:
| State | Owner | How windows see it |
|---|---|---|
| Session, account, permissions | Main process / persisted store | Read on load; refreshed via events after a change |
| App preferences, theme | Main process store | Broadcast on change; renderers project it locally |
| Per-document working copy | That document's renderer | Not shared; persisted through the main process |
| "Which window is active" | Main process, from focus events | Tray/menu actions target the focused window |
When the user changes a theme in settings, the main process updates the store and broadcasts theme:changed; the settings window and the main window both re-render from that event. No renderer asks another renderer what the theme is.
Reclaiming resources and recovering from a crashed renderer
A closed renderer still leaves traces if the main process holds closures, listeners, or large buffers tied to its webContents. On closed, remove IPC listeners registered for that window, drop cached data held only for it, and let the registry entry delete itself.
Watch for renderer failure with render-process-gone, which covers crashes and OOM kills:
win.webContents.on("render-process-gone", (_event, details) => {
console.error("renderer gone:", details.reason, details.exitCode);
// Release anything tied to this webContents, then decide whether to
// reload the window, reopen it, or record the crash for reporting.
wm.allWebContents(); // inspect remaining windows before cleanup
win.reload();
});
Estimate memory per renderer before shipping many concurrent windows: each carries its own V8 heap, DOM, and caches. Reuse panels, release documents, and keep large files out of renderer memory so N windows do not multiply one leak by N.
Common pitfalls
| Pitfall | Why it bites | Fix |
|---|---|---|
| Caching a closed window | Calling methods on a destroyed BrowserWindow throws; the reference masks the leak |
Delete the registry entry on closed; look windows up by kind/key rather than caching |
| Renderers reaching for each other | Isolation breaks; hidden coupling between pages; brittle after a reload | Route every message through ipcMain; preload exposes only a narrow bridge |
| Duplicate windows for the same document | Same item opened twice; two editors fighting over one file | Look up by document key before opening; focus the existing window |
| Shared session where isolation was meant | One window's cookies/cache leak into another | Choose partition deliberately; use a separate or in-memory partition for guest/preview |
| Heavy work in a second renderer | Another full UI surface for a background task; crash takes a window down | Move background work to a utilityProcess; keep windows for UI |
| Per-renderer global state | Two windows disagree after a change; stale copies | Main process holds the source of truth; windows project and react to events |
| Platform default on close | macOS keeps the app in the dock, Windows quits; behavior surprises users | Set the close behavior explicitly per platform; track app.isQuitting
|
| Leaking listeners on close | Old webContents handlers fire into a dead window |
Remove listeners and cached data when the window closes |
| No recovery after renderer crash | One OOM kill leaves a blank, unresponsive window | Handle render-process-gone; reload or reopen and report the crash |
Checklist
- [ ] All
BrowserWindowconstruction goes through aWindowManagerfactory with a registry. - [ ] A
requestSingleInstanceLockprevents a second instance;second-instancefocuses the existing window and passes arguments. - [ ] Each window kind has an explicit reuse-versus-destroy decision.
- [ ] Closed windows are removed from the registry; no stale references are kept.
- [ ]
partitionis chosen per window's sharing needs; less-trusted content gets an isolated session. - [ ] Every window keeps
contextIsolation: true,nodeIntegration: false,sandbox: true. - [ ] Background CPU-heavy or flaky code runs in a
utilityProcess, not in a second renderer. - [ ] All cross-window messages go through the main process (broadcast or invoke/reply); renderers do not touch each other.
- [ ] Global state has a named owner; windows receive changes via events instead of polling each other.
- [ ]
render-process-goneis handled, listeners and caches are released on close, and memory per renderer is measured before opening many concurrent windows.
Top comments (0)