Skip to content

Window Pooling

For windows that appear and disappear frequently — overlays, HUDs, menus — pooling pre-warms hidden windows so display is instant.

Without pooling, each open={true} creates a new BrowserWindow and loads its content. With pooling, a window is ready before you need it.

import { PooledWindow, createWindowPool } from "@loc/electron-window";
// Create once at module level — NOT inside a component
const overlayPool = createWindowPool(
{ transparent: true, frame: false }, // shape — creation-only props
{ minIdle: 1, maxIdle: 3, idleTimeout: 30000 }, // pool config
);
function App() {
const [show, setShow] = useState(false);
return (
<PooledWindow pool={overlayPool} open={show} alwaysOnTop>
<Overlay />
</PooledWindow>
);
}

On open={true}: acquires a pre-warmed window from the pool instantly. On open={false}: hides and returns to pool — no destroy/recreate cost.

createWindowPool(shape, config?, options?)

Section titled “createWindowPool(shape, config?, options?)”

Shape props are fixed for the lifetime of the pool. All windows in the pool share the same shape.

PropDescription
transparentTransparent background
frameWindow chrome
titleBarStyleTitle bar appearance
vibrancymacOS vibrancy effect

Changing these after pool creation has no effect — recreate the pool instead.

OptionTypeDefaultDescription
minIdlenumber1Minimum windows to keep warm in the pool
maxIdlenumber3Maximum idle windows to retain
idleTimeoutnumber30000Milliseconds before excess idle windows are destroyed

The third argument to createWindowPool is an options object:

OptionTypeDefaultDescription
injectStyles"auto" | false | (doc) => void"auto"How to copy styles into pool windows. false for CSS-in-JS frameworks. The function form replaces auto mirroring.
onWindowSetupWindowSetupCallbackPer-window setup hook. Runs once for each window the pool creates, after styles and #root are in place and before React portals content in. Must be synchronous. Additive — runs regardless of injectStyles. Optionally return a cleanup.
const overlayPool = createWindowPool(
{ transparent: true, frame: false },
{ minIdle: 1, maxIdle: 3 },
{ injectStyles: false }, // CSS-in-JS: runtime handles injection
);

Each pooled window is its own browsing-context realm — its own customElements registry, its own prototypes, its own document. Code that registers globals on the parent window at import time isn’t visible inside the pooled window.

onWindowSetup runs once per window the pool creates — including pre-warmed idle windows — right after <base>, styles, and the #root portal container are in place, and before React portals any content in. That ordering is the point: custom elements registered here upgrade synchronously as React inserts them, with no flash of unupgraded content the way a useLayoutEffect (which runs after mount) would risk. Use it for realm-specific initialization without giving up the default injectStyles: "auto" style mirroring:

const popoutPool = createWindowPool(
{ frame: true },
{ minIdle: 1 },
{
onWindowSetup: (childWindow, doc) => {
// Register a custom element on the child realm
if (!childWindow.customElements.get("my-widget")) {
childWindow.customElements.define(
"my-widget",
class extends childWindow.HTMLElement {},
);
}
// Patch a prototype, sync a documentElement attribute, etc.
doc.documentElement.dataset.theme = document.documentElement.dataset.theme;
// Optional: cleanup runs when the window is destroyed (pool teardown,
// idle eviction, or external close — not on hide/release back to pool).
return () => {
/* ... */
};
},
},
);

Compared to the function form of injectStyles, which replaces the auto <style>/<link> mirroring entirely, onWindowSetup is additive and runs in any injectStyles mode.

The hook must be synchronous. An async hook’s rejection escapes the error guard and its returned cleanup is silently dropped — TypeScript won’t warn (its void return rule is permissive), but the library detects it at runtime and logs an error. Do dynamic imports at module scope; keep the hook itself sync. The full contract — error containment, partial-failure rollback, the Window & typeof globalThis parameter type — is on the exported WindowSetupCallback type.

For non-pooled <Window>, you don’t need onWindowSetup — the window’s lifetime matches your component’s, so useWindowDocument() + useLayoutEffect does the same job at the same time.

PooledWindow accepts most <Window> props except creation-only shape props (those are fixed by the pool). Behavior props — alwaysOnTop, title, event handlers, etc. — can vary per use and update live while open.

Not applicable to pooled windows (TypeScript error if passed):

  • Shape props (transparent, frame, titleBarStyle, vibrancy) — fixed by the pool definition
  • injectStyles — fixed by the pool definition (third arg to createWindowPool)
  • targetDisplay — pool windows are pre-created; this only affects placement at creation time
  • persistBounds — pool windows are reused; per-key persistence doesn’t fit the model
  • recreateOnShapeChange — pool shape is immutable
PropTypeDescription
poolWindowPoolDefinitionPool created by createWindowPool
openbooleanAcquire or release from pool

All other <Window> props are supported.

Pool windows use hideOnClose internally. When the user clicks the X button:

  1. The window hides (doesn’t destroy)
  2. onUserClose fires
  3. onClose does not fire (the BrowserWindow still exists)

This differs from <Window>, where clicking X destroys the window and onClose fires. The typical pattern is the same though — your onUserClose handler sets open={false}, which releases the window back to the pool:

<PooledWindow pool={overlayPool} open={show} onUserClose={() => setShow(false)}>

onClose fires only when the underlying BrowserWindow is actually destroyed — via destroyWindowPool(), main-process shutdown, or eviction when idle exceeds maxIdle.

Pools outlive React. The pool instance is cached against the WindowPoolDefinition object’s identity and shared across every <PooledWindow> that receives it. Unmounting <PooledWindow> — or even <WindowProvider> — does not destroy the pool’s idle BrowserWindows. They stay hidden and warm, ready for the next mount.

This is usually what you want: the whole point of pooling is that the window already exists when you need it. But there are cases where you need to explicitly tear down a pool.

The pool cache is keyed by object identity. If the WindowPoolDefinition you pass to <PooledWindow pool={...}> changes identity, the library creates a new pool — and the old pool’s idle windows are orphaned in the main process with nothing to clean them up.

// DON'T — new pool every render, leaks one hidden window per render
<PooledWindow pool={createWindowPool({})} open={show}>
// DON'T — new pool whenever `userId` changes, leaks per user-switch
const pool = useMemo(() => createWindowPool({}), [userId]);
// DO — module-scope constant, stable for the life of the bundle
const overlayPool = createWindowPool({});
function App() {
return <PooledWindow pool={overlayPool} open={show}>...</PooledWindow>;
}

A dev-mode warning fires if <PooledWindow> sees a second distinct pool identity. The eventual production symptom is the maxWindows limit (50 by default) being hit with no obvious cause.

Destroys all idle and active windows belonging to a pool and removes it from the cache. Call this when the pool will genuinely never be used again:

import { destroyWindowPool } from "@loc/electron-window";
// Route/feature teardown — you're done with the overlay feature entirely
useEffect(() => {
return () => destroyWindowPool(overlayPool);
}, []);

Calling this while a <PooledWindow> is mounted and open will close the user’s window out from under them. The next open={true} throws "Pool has been destroyed" (caught and dev-warned by <PooledWindow>). Only call it when no consumer is mounted.

HMR re-evaluating the module that defines your pool creates a fresh WindowPoolDefinition object — same identity-churn leak as inline creation, just slower. Dispose the old pool before the module reloads:

const overlayPool = createWindowPool(
{ transparent: true, frame: false },
{ minIdle: 1 },
);
if (import.meta.hot) {
import.meta.hot.dispose(() => destroyWindowPool(overlayPool));
}

If you have several pools in one module, dispose each. The library has no way to do this automatically — it doesn’t know which module owns which pool.