DEV Community

Cover image for Electron Multi-Window Architecture: A Practical Guide to Window Lifecycle, Process Isolation, and Coordinating Several Windows Without Leaks
mp3sss
mp3sss

Posted on AI-assisted

Electron Multi-Window Architecture: A Practical Guide to Window Lifecycle, Process Isolation, and Coordinating Several Windows Without Leaks

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 small WindowManager in 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 partition values 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 utilityProcess isolates 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

  1. When one window stops being enough
  2. Windows, renderers, and the main process
  3. A window manager: factory and registry
  4. Single-instance lock and activating an existing window
  5. Lifecycle: reuse a hidden window or destroy it
  6. Process and session isolation
  7. Offloading work to a utility process
  8. Coordinating windows through the main process
  9. State ownership and avoiding drift
  10. Reclaiming resources and recovering from a crashed renderer
  11. Common pitfalls
  12. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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");
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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)),
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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 BrowserWindow construction goes through a WindowManager factory with a registry.
  • [ ] A requestSingleInstanceLock prevents a second instance; second-instance focuses 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.
  • [ ] partition is 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-gone is handled, listeners and caches are released on close, and memory per renderer is measured before opening many concurrent windows.

Top comments (0)