Desktop Apps with Electron: Main vs Renderer, Secure Preload IPC, Auto-Update and Packaging
Key takeaways
Electron lets you build macOS, Windows, and Linux desktop apps using web technologies. This guide covers the main/renderer process model, secure IPC, native OS integration, and distribution — with a React + Vite setup.
Electron wraps your web app in a desktop shell with access to the filesystem, system tray, notifications, and more. This guide covers the architecture, secure IPC, native APIs, and packaging for distribution.
Real-world insight: VS Code, Slack, Discord, Figma, and 1Password are all built with Electron — it’s proven for production desktop apps used by millions.
The single most important thing to internalize before writing any Electron code is that an Electron app is really two separate programs running together, not one — a Node.js process with full OS access, and a Chromium browser window with none. Everything in this guide, from the security warnings to the IPC boilerplate, exists because of that split; skipping past it and writing code as if it were “just a web app with extra APIs” is the single most common path to either broken code or a real security hole.
Setup (Vite + React + Electron)
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm install -D electron electron-builder @electron-toolkit/preload
Process Architecture
Electron has two process types:
Main Process (Node.js)
├── Creates browser windows
├── Manages app lifecycle
├── Accesses Node.js / native APIs
└── Communicates with renderer via IPC
Renderer Process (Chromium)
├── Renders your HTML/CSS/JS (React/Vue/etc.)
├── Cannot directly access Node.js APIs
└── Communicates with main via IPC (through preload)
Preload Script
├── Runs in renderer context with Node.js access
└── Bridge between main and renderer via contextBridge
This three-way split exists specifically because giving a Chromium renderer direct Node.js access (reading arbitrary files, spawning processes) would mean any code that runs inside that renderer — including any third-party script, ad, or embedded content, and critically any XSS vulnerability in your own renderer code — inherits full system access too. The preload script is the deliberate narrow gate: it runs with Node.js access but in the renderer’s context, and it’s the only place allowed to selectively expose a curated, safe subset of functionality to the untrusted renderer world via contextBridge — everything else in this guide (the IPC handlers, the security checklist) exists to enforce that boundary correctly rather than accidentally punching a hole through it.
Main Process
// src/main/index.ts
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
let mainWindow: BrowserWindow | null = null
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true, // required for security
nodeIntegration: false, // required for security
},
})
// Load Vite dev server in development, built files in production
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
// Open external links in default browser
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
contextIsolation: true and nodeIntegration: false are called out as “required for security” in the comments, and it’s worth understanding what specifically breaks without them: nodeIntegration: true would make Node.js globals (require, process, fs) directly available inside the renderer’s own JavaScript context — meaning any script that runs in that page, including a successful XSS payload, could immediately read/write the filesystem or spawn arbitrary processes. contextIsolation: true additionally ensures the preload script’s JavaScript context is genuinely separate from the page’s own context, so even a compromised page script can’t reach into or tamper with what the preload script set up — without it, a malicious page script could potentially override or spy on the very API the preload script exposes via contextBridge. The window-all-closed/activate pair handles a macOS-specific convention worth knowing: on macOS, apps conventionally stay running (visible in the Dock) after their last window closes, and activate fires when the user clicks the Dock icon to reopen a window — this pattern is what makes an Electron app behave like a native macOS app instead of quitting unexpectedly the moment the user closes the one open window.
Preload Script (Secure Bridge)
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'
// Expose safe APIs to renderer
contextBridge.exposeInMainWorld('api', {
// File operations
readFile: (path: string) => ipcRenderer.invoke('fs:readFile', path),
writeFile: (path: string, content: string) =>
ipcRenderer.invoke('fs:writeFile', path, content),
// Dialog
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (content: string) => ipcRenderer.invoke('dialog:saveFile', content),
// App info
getVersion: () => ipcRenderer.invoke('app:getVersion'),
// Events from main to renderer
onUpdate: (callback: (version: string) => void) => {
ipcRenderer.on('update-available', (_, version) => callback(version))
return () => ipcRenderer.removeAllListeners('update-available')
},
})
contextBridge.exposeInMainWorld is doing more than making functions callable from the renderer — it’s the actual security boundary in practice: only whatever is explicitly listed inside this object becomes reachable as window.api.*, and nothing else about the preload script’s own Node.js access leaks through. This is why the API surface here is deliberately narrow and task-specific (readFile, openFile, getVersion) rather than exposing something broad like the entire fs module directly — a renderer with unrestricted fs access would be functionally no safer than nodeIntegration: true, defeating the whole point of the preload boundary. ipcRenderer.invoke pairs with ipcMain.handle (covered next) for request/response calls that return a value via a Promise; the separate .on('update-available', ...) pattern here is for the other direction — the main process pushing an event to the renderer whenever it wants, not in response to a specific renderer request.
TypeScript types for renderer:
// src/renderer/types/electron.d.ts
interface Window {
api: {
readFile: (path: string) => Promise<string>
writeFile: (path: string, content: string) => Promise<void>
openFile: () => Promise<string | null>
saveFile: (content: string) => Promise<string | null>
getVersion: () => Promise<string>
onUpdate: (callback: (version: string) => void) => () => void
}
}
This Window interface augmentation is what makes window.api.readFile(...) type-check correctly in the renderer’s React code — TypeScript has no way to infer that a global window.api exists just because the preload script attached it at runtime, so this ambient declaration is what bridges the gap. Keeping this type definition in sync with the actual exposeInMainWorld call by hand (as shown here) is a real maintenance burden on a larger app — mismatches between the declared type and the actual runtime API are a genuine risk, which is why many real Electron+TypeScript projects derive this type from the preload script’s actual object shape (via typeof on a shared export) instead of duplicating it manually.
IPC Handlers (Main Process)
// src/main/ipc.ts
import { ipcMain, dialog, app } from 'electron'
import { readFile, writeFile } from 'fs/promises'
export function registerIpcHandlers() {
// File read
ipcMain.handle('fs:readFile', async (_, path: string) => {
return readFile(path, 'utf-8')
})
// File write
ipcMain.handle('fs:writeFile', async (_, path: string, content: string) => {
await writeFile(path, content, 'utf-8')
})
// Open file dialog
ipcMain.handle('dialog:openFile', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text Files', extensions: ['txt', 'md', 'json'] }],
})
if (result.canceled) return null
return readFile(result.filePaths[0], 'utf-8')
})
// Save file dialog
ipcMain.handle('dialog:saveFile', async (_, content: string) => {
const result = await dialog.showSaveDialog({
filters: [{ name: 'Text Files', extensions: ['txt'] }],
})
if (result.canceled || !result.filePath) return null
await writeFile(result.filePath, content)
return result.filePath
})
// App version
ipcMain.handle('app:getVersion', () => app.getVersion())
}
Worth flagging explicitly: this fs:readFile handler, as written, reads any path the renderer asks for with no validation — that’s fine for a first working version, but it’s exactly the gap the Security Checklist section further down closes by validating the requested path is within an allowed directory before touching the filesystem. Every ipcMain.handle is effectively a new API endpoint reachable from renderer code, and the same discipline that applies to a real server’s HTTP endpoints applies here: never trust that the caller only sends well-formed, safe input, because a compromised or buggy renderer can call any registered handler with arbitrary arguments.
Using the API in React
// src/renderer/App.tsx
import { useState, useEffect } from 'react'
export function Editor() {
const [content, setContent] = useState('')
const [filePath, setFilePath] = useState<string | null>(null)
const [version, setVersion] = useState('')
useEffect(() => {
window.api.getVersion().then(setVersion)
}, [])
async function openFile() {
const text = await window.api.openFile()
if (text !== null) setContent(text)
}
async function saveFile() {
const path = await window.api.saveFile(content)
if (path) setFilePath(path)
}
return (
<div>
<header>
<button onClick={openFile}>Open</button>
<button onClick={saveFile}>Save</button>
<span>v{version}</span>
</header>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
style={{ width: '100%', height: '80vh' }}
/>
</div>
)
}
Once the preload bridge and IPC handlers exist, window.api reads exactly like calling any other async browser API from React — useEffect + a Promise, useState for the result — with no visible trace of the process boundary it’s actually crossing underneath. This is the payoff the whole architecture is building toward: the renderer code has zero direct knowledge of Node.js, IPC channel names, or the main process at all, which keeps the React codebase itself portable (the same Editor component would need minimal changes to run as a plain web app against a real backend instead of Electron’s main process).
System Tray
// src/main/tray.ts
import { Tray, Menu, nativeImage, app } from 'electron'
import { join } from 'path'
export function createTray(mainWindow: BrowserWindow) {
const icon = nativeImage.createFromPath(join(__dirname, 'icon.png'))
const tray = new Tray(icon.resize({ width: 16, height: 16 }))
const menu = Menu.buildFromTemplate([
{ label: 'Show', click: () => mainWindow.show() },
{ label: 'Hide', click: () => mainWindow.hide() },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
])
tray.setToolTip('My App')
tray.setContextMenu(menu)
tray.on('click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show()
})
}
This is genuinely main-process-only functionality — Tray, Menu, and nativeImage all wrap native OS APIs (the macOS menu bar, Windows system tray, Linux equivalents) that have no meaning inside a Chromium renderer and no browser equivalent at all, which is exactly the category of capability the whole main/renderer split exists to provide access to. Resizing the icon to 16x16 explicitly is a real platform detail worth keeping: tray icons need to be small and provided at (or near) the exact pixel size the OS expects, and supplying an oversized source image without resizing produces a visibly blurry or incorrectly-scaled tray icon on most platforms.
Auto-Update
npm install electron-updater
// src/main/updater.ts
import { autoUpdater } from 'electron-updater'
export function setupAutoUpdater(mainWindow: BrowserWindow) {
autoUpdater.checkForUpdatesAndNotify()
autoUpdater.on('update-available', (info) => {
mainWindow.webContents.send('update-available', info.version)
})
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall()
})
}
Requires hosting releases on GitHub Releases, S3, or your own server.
This is the piece web developers coming to Electron most often underestimate the importance of: a web app deploys a new version and every visitor gets it on their next page load, automatically — a desktop app has no such guarantee, since users run whatever binary they downloaded until something tells their copy to update. electron-updater is that mechanism: checkForUpdatesAndNotify() polls the configured release host, and quitAndInstall() on update-downloaded applies it, typically on the next app restart rather than interrupting the user mid-session. Skipping auto-update entirely means shipping bug fixes and security patches only reach users who manually notice and reinstall — a meaningfully worse distribution story than virtually any web deployment model, which is why this is treated as a near-mandatory piece of a real Electron app rather than an optional nicety.
App Packaging (electron-builder)
// package.json
{
"build": {
"appId": "com.yourcompany.myapp",
"productName": "My App",
"directories": { "output": "dist-electron" },
"mac": {
"category": "public.app-category.productivity",
"target": ["dmg", "zip"]
},
"win": {
"target": ["nsis"]
},
"linux": {
"target": ["AppImage", "deb"]
},
"publish": {
"provider": "github",
"owner": "your-username",
"repo": "my-app"
}
}
}
# Build for current platform
npm run build
electron-builder
# Build for all platforms (requires macOS for signing)
electron-builder --mac --win --linux
The “requires macOS for signing” note is a real, easy-to-hit constraint worth planning around: Apple’s code-signing and notarization tooling only runs on macOS itself, so a CI pipeline building all three platforms typically needs at minimum a macOS runner for the Mac build specifically, even if Windows/Linux builds happen elsewhere — this is one of the more common surprises for teams setting up Electron CI for the first time. Code signing itself isn’t optional polish either: an unsigned app on macOS gets a Gatekeeper warning that scares most users away from running it at all, and an unsigned Windows installer similarly triggers SmartScreen warnings — both platforms treat an unsigned desktop binary as suspicious by default, which is worth budgeting real setup time (developer certificates, notarization credentials) for before a public release.
Security Checklist
new BrowserWindow({
webPreferences: {
contextIsolation: true, // ✅ must be true
nodeIntegration: false, // ✅ must be false
sandbox: true, // ✅ enable sandbox
webSecurity: true, // ✅ don't disable this
}
})
// ✅ Always validate IPC inputs
ipcMain.handle('fs:readFile', async (_, path: string) => {
// Validate path is within allowed directory
if (!path.startsWith(allowedDir)) throw new Error('Access denied')
return readFile(path, 'utf-8')
})
// ✅ Don't use shell.openExternal with user-provided input without validation
This checklist is best read as a direct fix-up of the earlier IPC handler example, not a separate abstract set of rules: the path-validation snippet here is specifically what the fs:readFile handler from Section 4 was missing, and it matters because Electron IPC handlers are trusted code running with full Node.js/filesystem access — an unvalidated path parameter is a genuine path-traversal vulnerability (../../etc/passwd-style), not a theoretical one, the exact same class of bug a web backend has to guard against on any endpoint that touches the filesystem based on user input. shell.openExternal with unvalidated input deserves the same suspicion for a related reason: passing a malicious javascript:/custom-protocol URL to it can potentially trigger unintended OS-level behavior, so any URL reaching it that originated from user input, a remote server, or renderer content should be validated as a genuine http(s):// URL first.
Related Articles
- React from Usage to Internals: Fiber Reconciliation, Diffing, Hooks and Concurrent Rendering
- Vite for Frontend Projects