Beyond the Browser with Electron
What changes when your React app gets a Node process, a filesystem, and a window of its own.
When you hear "Electron app," the image that probably comes to mind is a 200MB hello-world that drains your battery. Fair. That reputation is partly earned.
But Electron is also how a chunk of the apps you actually use every day get built. Let's look at what changes for your React code when it stops living in a tab, and what you need to know to ship something good on top of it.
What Electron Actually Is
Strip the marketing away and Electron is two processes glued together. One Node.js process that owns the app's lifecycle, and one or more Chromium tabs (renderers) that run your React.

If you mentally replace "main" with "your backend" and "renderer" with "your frontend," you'll get 80% of the design right on day one. The main process is your server. It just happens to live on the user's machine. The renderer is your web app. It just happens to have a window instead of a tab.
This mental model also clarifies the security story. Anything you render in a renderer could, in principle, run third-party code (an embedded iframe, a misbehaving dependency, an injected script). You do not want that code to have filesystem or shell access. That access belongs in main, behind a deliberate API.
Who's Already Doing This?
Turns out, a lot of the apps you keep open every day:
- VS Code, Cursor, Windsurf: all Electron, even after years of "we'll port it natively" speculation.
- Slack, Discord, Notion, Linear, Loom, 1Password, Postman, Figma's desktop, Obsidian: all Electron.
- GitHub Desktop, Skype, WhatsApp Desktop, Microsoft Teams: same family.
The pattern: Electron wins where you need a Node runtime under your React, a native menu bar, multi-window UX, or hardware access the browser refuses. Everywhere else, a PWA is still a fair fight.
Two Processes, One Mental Model
The single most important thing to internalise: a single Electron app is at least two OS-level processes, sometimes many more.
| Process | What it is | What it owns |
|---|---|---|
| Main | A Node.js process | App lifecycle, windows, menus, native modules, filesystem, network |
| Renderer | A Chromium tab | Your React tree, the DOM, web APIs |
| Preload | A script with limited Node access | Bridge between main and renderer |
| UtilityProcess | An optional sandboxed Node process | Background work that shouldn't block main |
| GPU | Chromium's GPU process | Hardware-accelerated rendering |
You always have one main process. You typically have one renderer per BrowserWindow. Preload scripts run inside each renderer but with brief, controlled Node access. The rest are optional but worth knowing about.
Think of It Like a Restaurant
Three roles, one place.
- The renderer is the front of house. Takes orders, shows the menu, smiles at customers. Cannot touch the stove.
- The main process is the kitchen. Owns the knives, the gas, the freezer, the vendor relationships.
- IPC is the order ticket that flies between them. Short, structured, async.
The customer never walks into the kitchen. The kitchen never serves a table directly. Every interaction goes through a ticket.
Mess that up (front of house with access to gas, kitchen serving directly) and the place is unsafe. Get it right and the operation runs.
The IPC Tax, In Code
The main and renderer talk via IPC. You almost never want to use Electron's raw ipcMain and ipcRenderer from your React code directly. You want a thin, typed surface exposed through contextBridge.
Here's the canonical three-file setup.
preload.ts runs inside the renderer process but briefly has access to Node. It defines what your React code is allowed to call.
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("desktop", {
// Simple request/response
getRecordings: () => ipcRenderer.invoke("recordings:list"),
saveAs: (path: string, buffer: ArrayBuffer) =>
ipcRenderer.invoke("file:saveAs", path, buffer),
// One-way event from renderer to main
reportFps: (fps: number) => ipcRenderer.send("metrics:fps", fps),
// Subscribe to events from main
onSampleBatch: (cb: (batch: Float32Array) => void) => {
const handler = (_: unknown, batch: Float32Array) => cb(batch);
ipcRenderer.on("device:samples", handler);
return () => ipcRenderer.removeListener("device:samples", handler);
},
});
main.ts is the only place that touches the disk, the device, or the network in a privileged way.
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import { writeFile } from "node:fs/promises";
ipcMain.handle("file:saveAs", async (_event, defaultPath, buffer) => {
const result = await dialog.showSaveDialog({ defaultPath });
if (result.canceled || !result.filePath) {
return { ok: false, canceled: true };
}
await writeFile(result.filePath, Buffer.from(buffer));
return { ok: true, path: result.filePath };
});
Your React component consumes the bridge with the same shape as any async API.
async function exportRecording(id: string) {
const data = await fetchRecording(id);
const result = await window.desktop.saveAs(`${id}.csv`, data);
if (result.ok) toast.success(`Saved to ${result.path}`);
}
The flow on a single IPC call looks like this:

Three things to remember about every one of those arrows:
- Every IPC call is async. Treat each one like a network round trip. It's faster than a real network call, but it isn't free, and you don't want to do thousands per second in a hot loop.
- Serialisation is real. Anything you send gets cloned via the structured clone algorithm. Class instances lose their prototype. Functions cannot cross at all. Plan your API around plain data,
ArrayBuffer,MessagePort, andTransferabletypes. contextBridgeis non-negotiable. SettingnodeIntegration: trueandcontextIsolation: falsein 2026 is roughly equivalent to leaving the front door open with a "free Node.js inside" sign. Don't.
The Security Model: Why contextBridge Matters
A renderer is hostile territory by default. Any third-party script, ad, dependency, or compromised library you load could ask for require('fs') if you let it. contextBridge is the wall between "code we trust" (main) and "code we don't fully trust" (the rendered tree).
The secure defaults you want in every BrowserWindow:
new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true, // separate JS context for preload and renderer
nodeIntegration: false, // no Node globals in the renderer
sandbox: true, // OS-level sandbox for the renderer process
},
});
With these on, your React code lives in a pure browser context. It can only do anything privileged by calling functions you explicitly exposed via contextBridge. A compromised dependency cannot read the filesystem, spawn processes, or talk to your USB devices unless your contract allows it.
If your team's instinct is to disable contextIsolation because "it's annoying," push back. The annoyance is the security model working as designed.
Performance: When IPC Becomes the Bottleneck
The IPC tax shows up most clearly when you naively wrap a hot SDK call. Sending one frame across the bridge per sample at 1000 Hz will saturate IPC and stutter your UI.
The fix is to keep the hot loop in main, do the buffering and decimation in Node, and only push aggregated frames across to the renderer at the rate the renderer can consume.
// main.ts: buffer samples, flush at 60 Hz
let buffer: number[] = [];
device.on("sample", (sample) => buffer.push(sample));
setInterval(() => {
if (buffer.length === 0) return;
const batch = new Float32Array(buffer);
buffer = [];
// Send as a Transferable so the buffer isn't cloned.
mainWindow.webContents.send("device:samples", batch);
}, 1000 / 60);
// React: subscribe via the preload-exposed API
useEffect(() => {
return window.desktop.onSampleBatch((batch) => {
sampleStore.appendBatch(batch);
});
}, []);
Two patterns worth knowing:
MessagePortfor streaming: instead of going throughipcMain/ipcRenderer, you can hand aMessagePortto the renderer and send messages directly over it. Lower overhead for high-throughput channels.SharedArrayBufferfor zero-copy (when crossOriginIsolated is set): both processes see the same memory. Useful for very large buffers that change frequently.
Native Modules: Where the Browser Stops
The real reason teams pick Electron is usually a native SDK. A vendor library, a USB or serial protocol, a C++ binding, a ZMQ bridge to a hardware service. The browser is not going to help. Node, with a native addon, will.
The contract is simple: only the main process can require the native module. The renderer talks to it through IPC.
A clean pattern is to wrap the SDK in a small "device service" in main, with a clear lifecycle:
// main/deviceService.ts
import { Device } from "@your-vendor/sdk";
type Listener = (frame: SampleFrame) => void;
let device: Device | null = null;
const listeners = new Set<Listener>();
export async function connect(deviceId: string) {
if (device) throw new Error("Already connected");
device = await Device.open(deviceId);
device.on("sample", (frame) => listeners.forEach((l) => l(frame)));
device.on("error", (err) => console.error("device:", err));
}
export async function disconnect() {
await device?.close();
device = null;
listeners.clear();
}
export function onSample(l: Listener) {
listeners.add(l);
return () => listeners.delete(l);
}
The renderer never imports the SDK, never sees the device handle, never has to recover from a native crash directly. If the SDK throws, you catch it in main and report a clean error over IPC.
Native module pain (the one nobody warns you about)
Native modules compile against a specific Node ABI. Electron ships its own Node version, which is rarely the latest. So a module that works in your normal Node project may fail to load in Electron.
The fix is @electron/rebuild, run automatically after npm install:
{
"scripts": {
"postinstall": "electron-rebuild"
}
}
And CI that builds on the user's actual OS and arch. If you ship for macOS Apple Silicon, macOS Intel, Windows x64, and Linux x64, your CI matrix is four jobs minimum, all running electron-rebuild against the matching Electron version.
Multi-Window, Menus, and Other Desktop Things
Once you leave the tab, you inherit the rest of the desktop.

A few things to internalise:
Multi-window state. Each BrowserWindow is a separate renderer with its own memory. State is not shared by default. Put the source of truth in main and let each renderer subscribe via IPC. Two windows trying to sync state through localStorage is a path of suffering.
// main: broadcast a state change to all renderers
function broadcastSubjects(subjects: Subject[]) {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send("subjects:update", subjects);
}
}
Native menus. macOS expects a real menu bar. Set one once in main:
import { Menu } from "electron";
const template: Electron.MenuItemConstructorOptions[] = [
{
label: "File",
submenu: [
{ label: "New Recording", accelerator: "CmdOrCtrl+N",
click: () => mainWindow.webContents.send("menu:new-recording") },
{ type: "separator" },
{ role: "quit" },
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
System tray, notifications, deep links, file associations. All main-process concerns. All worth wiring up early so they don't get bolted on awkwardly later.
UtilityProcess: When Workers Aren't Enough
Web Workers and SharedWorker work inside renderers. They share Chromium's renderer process. They cannot require Node modules.
Sometimes you need a sandboxed Node process that's separate from main. Electron's UtilityProcess (added in 28+) gives you exactly that.
import { utilityProcess } from "electron";
const proc = utilityProcess.fork(
path.join(__dirname, "workers/decoder.js"),
[],
{ stdio: "pipe" },
);
proc.postMessage({ type: "decode", buffer });
proc.on("message", (msg) => {
if (msg.type === "decoded") {
mainWindow.webContents.send("device:samples", msg.samples);
}
});
Use it for:
- Heavy decoding / parsing that would block main.
- Sandboxed plugin execution.
- Anything you'd run in a worker thread but need IPC and Node access for.
Cheaper than a full BrowserWindow with show: false, isolated from main if it crashes.
Auto-Update and the Pain of Signed Builds
Auto-update is the difference between "Electron is great" and "Electron is hell," and it's all about getting one thing right: shipping a signed, notarised build that your user's installed copy can verify.
The usual stack is electron-updater (from electron-builder) or Electron's native Squirrel integration. The mental model is the same:

Code-wise, electron-updater is a few lines:
import { autoUpdater } from "electron-updater";
app.whenReady().then(() => {
autoUpdater.checkForUpdatesAndNotify();
});
autoUpdater.on("update-downloaded", () => {
autoUpdater.quitAndInstall();
});
The work is in the build pipeline:
- macOS: developer ID signing, notarisation via
xcrun notarytool, stapling the ticket. - Windows: code signing with an EV certificate (or accept SmartScreen warnings for a few months).
- Linux: depends on the distribution channel (AppImage, Snap, Flatpak, deb, rpm).
Test the auto-update path before your first public release. The first time you ship a broken auto-update is the last time you ship that app. There's no recovery path other than asking users to download manually.
The Failure Modes Nobody Mentions
A few things you'll learn the hard way if you don't read them here first.
- Bundle size. Electron drags Chromium and Node. Your "small" desktop app is at least 100MB. Plan for that in CI artefacts and DMG/installer sizes. Treeshake the renderer aggressively. Don't bundle the same dependency in main and renderer if you can help it.
- Memory. Each window is a Chromium process with its own footprint. Three windows is roughly three browser tabs of RAM, and leaks compound across long sessions. Audit with the Chromium memory profiler the same way you would a web app, but expect higher baselines.
- The
app.getPathminefield. The user data directory is not~. It's platform-specific. Hardcoded paths will work on your laptop and break on someone else's. Always go throughapp.getPath('userData'),app.getPath('downloads'), etc. - DevTools in production. Open by accident in a packaged build and users will see a console full of warnings. Gate
webContents.openDevTools()behind a build-time flag. - Native module ABI drift. Native modules compile against a specific Node ABI. Electron ships its own Node version.
electron-rebuildis your friend. CI matrices that build on the user's actual OS and arch are not optional. - App lifecycle quirks. Quitting on macOS keeps the app running by default (the dock icon stays). Wire up
before-quit,window-all-closed, andactivateexplicitly or your app will feel weird on at least one OS.
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
Should You Pick Electron?
Probably not, honestly. Most apps don't need it.
But Electron earns its keep when at least one of these is true:
- A native SDK or hardware integration has to run in Node.
- You need multi-window or system-level UX (tray, menus, deep links, file associations) that PWAs cannot match.
- You need offline-first behaviour with local storage that survives across sessions, with a UX that doesn't feel like a website that lost connection.
- You ship to enterprise customers who won't run a web app and want an installable, signed artefact.
If none of those apply, the bundle size and update complexity aren't worth it. A PWA covers a remarkable amount of ground in 2026, and Tauri is increasingly competitive when you need a desktop binary without dragging Chromium along.
What Carries Over From Web React
The good news: 95% of your React knowledge transfers. Components, hooks, state management, routing, the lot. The renderer is just Chromium with extra abilities. React DevTools works. The Profiler works. Hot reload works.
What changes is the shape of the application. You're now writing two apps: the renderer (your React tree) and the main process (your Node service). The interesting design questions live at the boundary between them:
- What goes in IPC, what stays in the renderer?
- What shape does the API between them take?
- What stays in main forever, and what's safe to send across?
Get the boundary right and Electron feels like a superpower. Get it wrong and you've built a slow website wrapped in 200MB of binary.
Wrapping Up
Going beyond the browser isn't really about Electron. It's about accepting that your app has a backend now, and that backend lives on the user's machine. Once you internalise that, the rest (IPC, native modules, windows, packaging, auto-update) is just engineering.
Three rules that have served me well:
- Design the IPC surface like a real API. Versioned, typed, plain data, async.
- Keep the renderer assuming nothing about Node. Future-you will thank you when you can run the renderer in a browser tab for testing.
- Sign and update your builds before users notice. Set up notarisation and auto-update on day one, not the week before launch.
Most of the pain in Electron apps lives in the spaces between those rules.
#electron #reactjs #desktop #javascript #ipc