Skip to main content

Electron.js

What is Electron?

Electron is a framework for building cross-platform desktop apps using web technologies (HTML, CSS, JS). It works by bundling Chromium (for rendering UI) and Node.js (for system-level access) into a single runtime, so a web app can also read files, spawn processes, create native menus, etc.

Notable apps built with it: VS Code, Slack, Discord, Figma (desktop), Notion.

Trade-off to remember: you get "write once, ship everywhere" desktop apps, but at the cost of shipping an entire Chromium + Node runtime per app (~100–200MB installs) and higher memory usage than a native app.


Core architecture: Main vs Renderer

Electron apps are multi-process, mirroring Chromium's own architecture.

Main process

  • Entry point of the app (the file referenced in package.json's "main").
  • Exactly one main process per app.
  • Runs in a full Node.js environment.
  • Owns app lifecycle, creates/controls BrowserWindow instances, has access to native OS APIs (menus, tray, dialogs, notifications, file system).
  • Think of it as the "backend" of your desktop app.

Renderer process

  • One renderer process per BrowserWindow (or <webview>).
  • Runs the actual web page (your UI) inside a Chromium browser context.
  • By default (modern Electron), renderers are sandboxed and do not have direct Node.js or Electron API access — this is a deliberate security boundary, not a limitation to "fix" by disabling it.
  • Think of it as the "frontend."

Why the split matters

Chromium's multi-process model means a crash in one renderer (one window/tab) doesn't take down the whole app. It also means untrusted or remote web content (if you ever load any) is isolated from OS-level capabilities.

┌─────────────────────┐
│ Main Process │ Node.js, full OS access, app lifecycle
│ (background.js) │
└─────────┬────────────┘
│ creates

┌─────────────────────┐ ┌─────────────────────┐
│ Renderer Process #1 │ │ Renderer Process #2 │
│ (BrowserWindow, UI) │ │ (another window) │
│ Sandboxed, no Node │ │ Sandboxed, no Node │
└─────────────────────┘ └─────────────────────┘

The preload script (the bridge)

Since renderers can't touch Node/Electron APIs directly, you need a preload script — it runs in a special context that has access to a limited set of Node APIs and the renderer's window, before the web page loads.

The safe pattern is contextBridge + ipcRenderer, never just setting nodeIntegration: true and calling it done.

// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('api', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
onUpdateCounter: (callback) =>
ipcRenderer.on('update-counter', (_event, value) => callback(value)),
})
// renderer.js (in your web page, no Node access, but window.api exists)
document.getElementById('open-btn').addEventListener('click', async () => {
const filePath = await window.api.openFile()
console.log(filePath)
})

Mental model: the preload script is like a controlled API surface you hand to the frontend — you decide exactly which functions it can call, instead of exposing all of Node.


IPC (Inter-Process Communication)

Because main and renderer are separate processes, they can't just call each other's functions directly — they talk via message passing.

PatternDirectionAPI
Renderer → Main (fire and forget)one-wayipcRenderer.send / ipcMain.on
Renderer → Main (request/response)two-wayipcRenderer.invoke / ipcMain.handle
Main → Rendererone-waywebContents.send / ipcRenderer.on
// main.js
const { ipcMain, dialog } = require('electron')

ipcMain.handle('dialog:openFile', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog()
return canceled ? null : filePaths[0]
})

invoke/handle is the modern preferred pattern for anything that returns a value, since it's promise-based instead of relying on manually named "reply" channels.


Key building blocks / APIs

  • app — app lifecycle events (ready, window-all-closed, before-quit), path helpers (app.getPath('userData')), single-instance locking.
  • BrowserWindow — creates and controls a native OS window that hosts a renderer. Configured via webPreferences (this is where preload, contextIsolation, sandbox, nodeIntegration live).
  • Menu / MenuItem — native application and context menus.
  • Tray — system tray / menu bar icon.
  • dialog — native open/save/message boxes.
  • shell — interact with the OS shell (open external URLs/files in the default app, move items to trash).
  • Notification — native OS notifications.
  • autoUpdater — built-in auto-update mechanism (commonly paired with a service like electron-builder + GitHub Releases, or Squirrel).
  • webContents — the underlying renderer content object; used for things like send(), executing JS, printing to PDF, devtools control.

Security checklist

Electron's security model assumes you'll load your own trusted local files, but the defaults exist because historically many apps got this wrong. Rules of thumb:

  1. Keep contextIsolation: true (default since Electron 12). Keeps the preload script's JS context separate from the page's, so a compromised page can't reach into Node internals via prototype pollution.
  2. Keep nodeIntegration: false in renderers (default). Never enable this just to avoid writing a preload bridge.
  3. Keep sandbox: true where possible (default since Electron 20).
  4. Only expose the specific functions a renderer needs via contextBridge, not entire modules (ipcRenderer itself, fs, etc).
  5. Validate/sanitize anything coming over IPC from the renderer in the main process — treat it like input from an untrusted client, especially if you ever load remote content.
  6. Set a Content-Security-Policy if loading any remote/web content.
  7. Don't disable webSecurity.

Packaging & distribution

Electron itself doesn't produce installers — you use a separate tool:

  • electron-builder — most popular; handles NSIS installers (Windows), DMG (macOS), AppImage/deb/rpm (Linux), code signing, and auto-update feeds in one config.
  • Electron Forge — official Electron team tool; more "batteries included" scaffolding (webpack/vite templates, makers, publishers).
  • electron-packager — lower-level, just bundles the app without installers.

Typical package.json scripts:

{
"scripts": {
"start": "electron .",
"package": "electron-builder --dir",
"dist": "electron-builder"
}
}

Code signing matters in practice: unsigned macOS builds get Gatekeeper warnings, and unsigned Windows builds trigger SmartScreen warnings.


Common gotchas / lessons learned

  • __dirname behaves as expected in the main process, but be careful with paths after packaging (app.asar) — files inside the asar archive aren't a normal filesystem path for some Node APIs; use app.getAppPath() / path.join carefully, or asarUnpack specific files if needed.
  • Opening external links: use shell.openExternal(url), and intercept window.open / navigation events (will-navigate, setWindowOpenHandler) so links don't hijack your app window or open unsandboxed windows.
  • Memory: each BrowserWindow is basically a Chrome tab's worth of overhead — don't spin up dozens of hidden windows if you can avoid it.
  • Dev vs prod loading: typically win.loadURL('http://localhost:5173') in dev (pointing at a Vite/webpack dev server) and win.loadFile('dist/index.html') in production.
  • Native modules (anything with a .node binary, e.g. better-sqlite3) need to be rebuilt against Electron's Node/ABI version — tools like electron-rebuild or @electron/rebuild handle this.

Minimal working example

// main.js
const { app, BrowserWindow } = require('electron')
const path = require('node:path')

function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
win.loadFile('index.html')
}

app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})

Further reading