Electron Desktop App Development Complete Guide: From Setup to Production
Why Electron
Cross-platform desktop development has always been a headache. Write Windows apps in C# and WPF, macOS apps in Swift and AppKit, Linux apps in GTK or Qt — three codebases, triple the maintenance. Electron changed the game: one HTML/CSS/JavaScript codebase, packaged for Windows, macOS, and Linux simultaneously.
If you use VS Code, Figma, Slack, or Discord, you're already running Electron apps every day. VS Code's startup speed and Figma's rendering performance prove that Electron can match native experience with the right architecture.
I helped migrate an internal tool from C# WinForms to Electron. Feature delivery speed tripled over the next two quarters. The reason is simple: hiring frontend developers is far easier than hiring C# desktop developers, and the component ecosystem is vastly richer.
This article is my complete post-migration summary. I won't recite API documentation — you can look that up — I'll focus on what actually goes wrong in production and how to fix it.
Project Initialization: Don't Copy the Official Quick-Start
The official electron-quick-start uses bare JavaScript, no build tooling, no modules, no hot reload. Starting a real project this way guarantees spaghetti within two months.
Use electron-vite or manually set up Vite + Electron:
npm create @quick-start/electron@latest my-app -- --template vue-ts
Or manual setup:
mkdir my-electron-app && cd my-electron-app
npm init -y
npm i -D electron electron-builder vite @vitejs/plugin-vue typescript
npm i vue
Recommended directory structure:
my-electron-app/
├── electron/ # Main process
│ ├── main.ts # Entry point, window creation
│ ├── preload.ts # Security bridge
│ └── ipc/ # IPC channel management
│ ├── file.ts # File operations
│ └── system.ts # System information
├── src/ # Renderer (frontend)
│ ├── App.vue
│ ├── main.ts
│ └── components/
├── resources/ # App icons, static assets
├── electron-builder.yml
├── vite.config.ts
└── package.json
The critical architectural decision: complete separation of main and renderer processes. The main process (electron/) handles only system-level operations. The renderer (src/) is a standard frontend project. This lets specialists on each side work independently without friction.
Process Model: Know Who Does What
Electron has two core processes. If you don't internalize this concept, security and performance issues will force repeated rewrites.
| Process | Runtime | Capabilities | Limitations |
|---|---|---|---|
| Main | Node.js | Create windows, read/write files, call system APIs, manage lifecycle | Cannot directly manipulate DOM |
| Renderer | Chromium | Render HTML/CSS, respond to user input, manipulate DOM | Cannot access Node.js APIs by default |
Communication happens through IPC (Inter-Process Communication). The main process exposes a limited API surface to renderers via contextBridge.
A typical communication flow:
User clicks "Save File"
→ Renderer: ipcRenderer.invoke('save-file', fileData)
→ Main: ipcMain.handle('save-file', async (event, data) => {
dialog.showSaveDialog() → fs.writeFileSync()
})
→ Returns result to renderer
Main Process Entry
// electron/main.ts
import { app, BrowserWindow } from 'electron';
import { registerIpcHandlers } from './ipc';
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // Must enable
nodeIntegration: false, // Must disable
sandbox: true, // Sandbox mode
},
});
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
}
app.whenReady().then(() => {
registerIpcHandlers();
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
Preload Script — Your App's Security Gate
// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (content: string, defaultName?: string) =>
ipcRenderer.invoke('save-file', content, defaultName),
openFile: () =>
ipcRenderer.invoke('open-file'),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getPlatform: () => process.platform,
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
});
Calling from the renderer:
// src/components/SaveButton.vue
const handleSave = async () => {
const result = await window.electronAPI.saveFile(
editorContent.value,
'untitled.txt'
);
if (result.success) {
showToast(`Saved to ${result.filePath}`);
}
};
contextBridge is crucial — it restricts what the renderer can call. Never expose raw ipcRenderer to the renderer; that would give web content full Node.js access.
Security: Seven Non-Negotiable Rules
Electron's security model has evolved through multiple iterations. Old tutorials often show now-unsafe patterns. As of 2026, here are the hard requirements:
| # | Rule | Config | Why |
|---|---|---|---|
| 1 | contextIsolation: true |
webPreferences | Isolates renderer JS context from preload |
| 2 | nodeIntegration: false |
webPreferences | Blocks direct Node.js require in renderer |
| 3 | sandbox: true |
webPreferences | OS-level sandbox, restricts process permissions |
| 4 | Use contextBridge |
preload.ts | Whitelist-based API exposure |
| 5 | Validate IPC message origin | ipcMain.handle | Check event.senderFrame URL |
| 6 | No remote content without CSP | webPreferences | CSP policy required for remote URLs |
| 7 | Sign and notarize | electron-builder | macOS requires notarization, Windows recommends signing |
IPC Origin Validation
// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
if (!event.senderFrame?.url.startsWith('file://')) {
throw new Error('Unauthorized caller');
}
if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
throw new Error('Invalid content');
}
const { filePath } = await dialog.showSaveDialog({
defaultPath: 'untitled.txt',
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (!filePath) return { success: false, canceled: true };
await fs.promises.writeFile(filePath, content, 'utf-8');
return { success: true, filePath };
});
Window Management: Beyond new BrowserWindow
Frameless Windows and Custom Title Bars
Most modern desktop apps abandon native title bars — VS Code, Figma, Notion all do this. Implementation:
const mainWindow = new BrowserWindow({
frame: false,
titleBarStyle: 'hidden',
...(process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset' }
: { frame: false }),
});
Custom title bar in the renderer:
<!-- src/components/TitleBar.vue -->
<template>
<div class="title-bar" :class="{ 'is-mac': isMac }">
<div class="title-bar-drag" @dblclick="toggleMaximize">
<span class="app-title">{{ title }}</span>
</div>
<div class="window-controls" v-if="!isMac">
<button @click="minimize" class="control-btn minimize">─</button>
<button @click="toggleMaximize" class="control-btn maximize">□</button>
<button @click="close" class="control-btn close">✕</button>
</div>
</div>
</template>
Note -webkit-app-region: drag — this CSS property enables drag-to-move on the title bar area. Button areas must override it with no-drag, otherwise clicking a button triggers window dragging.
Multi-Window Communication
Complex apps often need multiple windows (settings, preview, dialogs). Route inter-window communication through the main process:
ipcMain.on('broadcast-to-windows', (event, channel: string, data: unknown) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win.webContents.id !== event.sender.id) {
win.webContents.send(channel, data);
}
});
});
Native Module Integration: When Node.js Isn't Enough
Some tasks are too slow in pure JavaScript — batch image processing, video encoding. That's where C++ native modules come in.
Option 1: Node.js Native Addon (node-addon-api)
// native/thumbnail.cc
#include <napi.h>
Napi::Value GenerateThumbnail(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string inputPath = info[0].As<Napi::String>();
std::string outputPath = info[1].As<Napi::String>();
// Use FFmpeg/libvips to generate thumbnail
return Napi::String::New(env, outputPath);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("generateThumbnail",
Napi::Function::New(env, GenerateThumbnail));
return exports;
}
NODE_API_MODULE(thumbnail, Init)
Option 2: Child Process Calling External Tools
Prefer this — decoupled, crashes don't affect the main process:
// electron/ipc/image.ts
import { spawn } from 'child_process';
ipcMain.handle('compress-image', async (event, inputPath: string) => {
return new Promise((resolve, reject) => {
const child = spawn('oxipng', ['--opt', 'max', '--strip', 'safe', inputPath]);
let stderr = '';
child.stderr.on('data', (data) => { stderr += data; });
child.on('close', (code) => {
if (code === 0) resolve({ success: true });
else reject(new Error(`oxipng exited with ${code}: ${stderr}`));
});
});
});
Auto-Update: Seamless Upgrades
Auto-update is standard for desktop apps. electron-updater (based on electron-builder) is the most mature option:
// electron/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow, dialog } from 'electron';
export function setupAutoUpdater(mainWindow: BrowserWindow) {
autoUpdater.setFeedURL({
provider: 'generic',
url: 'https://releases.your-app.com/updates/',
});
setInterval(() => {
autoUpdater.checkForUpdates();
}, 4 * 60 * 60 * 1000);
autoUpdater.on('update-available', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Update Available',
message: 'Downloading update in background...',
});
});
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Update Ready',
message: 'Restart now to install the latest version.',
}).then(() => {
autoUpdater.quitAndInstall();
});
});
autoUpdater.on('error', (err) => {
console.error('Update error:', err.message);
});
setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}
Packaging and Distribution
electron-builder.yml example:
appId: com.yourcompany.yourapp
productName: YourApp
directories:
output: release
files:
- dist/**/*
- electron/**/*.js
- package.json
win:
target:
- target: nsis
arch: [x64, arm64]
icon: resources/icon.ico
mac:
target:
- target: dmg
arch: [x64, arm64]
icon: resources/icon.icns
category: public.app-category.developer-tools
hardenedRuntime: true
notarize:
teamId: YOUR_TEAM_ID
linux:
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]
category: Development
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
Build commands:
npm run dev # local development
npm run build # current platform
npm run build:win # Windows
npm run build:mac # macOS
npm run build:linux # Linux
Performance Optimization: Don't Let Your App Become Chrome
Electron's biggest criticism is memory usage. A bare Electron app uses 50-80MB, and with actual content, easily 200MB+. Here are strategies validated in production:
1. Lazy-Load Non-Critical Windows
let settingsWindow: BrowserWindow | null = null;
ipcMain.handle('open-settings', async () => {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus();
return;
}
settingsWindow = new BrowserWindow({ /* ... */ });
settingsWindow.loadURL('...');
settingsWindow.on('closed', () => { settingsWindow = null; });
});
2. Background Throttling
const win = new BrowserWindow({
webPreferences: {
backgroundThrottling: true,
offscreen: false,
},
});
win.on('blur', () => {
win.webContents.setBackgroundThrottling(true);
});
3. Use BrowserView for Embedded Content
const view = new BrowserView({
webPreferences: { sandbox: true, contextIsolation: true },
});
mainWindow.setBrowserView(view);
view.setBounds({ x: 0, y: 0, width: 800, height: 600 });
view.webContents.loadURL('https://preview.your-app.com/doc/123');
4. Memory Leak Checklist
| Check | Tool | Metric |
|---|---|---|
| Zombie processes after window close | Task Manager / Activity Monitor | Lingering electron processes |
| Undestroyed webContents | webContents.getAllWebContents() |
Growing count |
| Uncleaned IPC listeners | ipcMain.eventNames() |
Missing removeHandler |
| Large object references | Chrome DevTools Memory panel | Heap snapshot comparison |
Debugging Tips
Main Process Debugging
# Launch with debugger attached
electron --inspect=5858 .
# Open chrome://inspect in Chrome to connect
VSCode launch.json:
{
"type": "node",
"request": "launch",
"name": "Debug Main Process",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"runtimeArgs": ["--inspect=5858", "."],
"port": 5858
}
Renderer Process Debugging
DevTools are automatically open in development mode. In production, force with a flag:
if (process.argv.includes('--devtools')) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
Common Pitfalls
Pitfall 1: macOS Notarization Fails
"notarization failed: The binary is not signed"
Fix: Ensure hardenedRuntime: true, correct entitlements file, and an Apple Developer ID certificate.
Pitfall 2: Windows Path Too Long
Windows has a 260-character path limit. Deeply nested node_modules easily exceed it. Enable long path support in Windows 10 1607+ via Group Policy, or use electron-builder install-app-deps.
Pitfall 3: Native Modules Fail After asar Packaging
electron-builder packages code into app.asar by default, but .node native modules can't load from inside asar:
# electron-builder.yml
asarUnpack:
- "node_modules/sharp/**"
- "node_modules/better-sqlite3/**"
- "native/**/*.node"
Pitfall 4: Long White Screen on Launch
- Code-split Vite output, load minimal code for first paint
- Use a splash screen to cover the white period
- Show window only on
ready-to-showevent:
mainWindow.once('ready-to-show', () => { mainWindow.show(); });
Related Tools
- Base64 Encode/Decode — Handle inline resources and file transfers
- JSON Formatter — Debug IPC messages and configuration files
- Image Compressor — Optimize in-app image asset size
Try these browser-local tools — no sign-up required →