Building Cross-Platform Desktop Applications with Electron: The Ultimate Step-by-Step Guide
In the ever-evolving landscape of software development, the ability to create desktop applications that run seamlessly on Windows, macOS, and Linux using web technologies has become a game changer. Electron, an open-source framework developed by GitHub, empowers developers to build native-like desktop apps with HTML, CSS, and JavaScript, leveraging the power of Chromium and Node.js. Whether you are a seasoned web developer looking to expand your skillset or a newcomer eager to create your first desktop tool, understanding Electron is essential. This guide will walk you through everything you need to know—from the fundamental architecture to advanced optimizations—so you can ship a polished, production-ready application.
Electron has powered some of the most popular applications in the world, including Visual Studio Code, Slack, Discord, and Figma. Its appeal lies in its ability to reuse existing web codebases, access native operating system APIs, and deliver a consistent user experience across platforms. However, building with Electron also comes with challenges, such as managing renderer processes, handling security concerns, and optimizing performance. In this comprehensive tutorial, you will learn not only how to set up your first Electron project but also how to structure a professional application, implement inter-process communication (IPC), package your app for distribution, and follow best practices to avoid common pitfalls. By the end of this article, you’ll be equipped with the knowledge to create your own desktop applications with confidence.
Understanding Electron’s Core Architecture
Before diving into code, it is crucial to understand how Electron works under the hood. Electron applications consist of two main types of processes: the main process and renderer processes. The main process runs the application’s entry point (typically main.js) and has full access to Node.js APIs, allowing it to interact with the file system, create native menus, spawn child processes, and manage the application lifecycle. Each window your application opens runs in its own renderer process, which is essentially a Chromium browser instance. Renderer processes render your web content (HTML, CSS, JavaScript) and can also access some Node.js APIs through a context bridge or preload scripts, but for security reasons, direct access to Node.js in renderers is disabled by default in modern Electron versions.
Communication between these processes is handled via Electron’s inter-process communication (IPC) module. The main process can send and receive messages using ipcMain, while renderer processes use ipcRenderer. This design ensures that the UI remains responsive while the main process handles system-level tasks. Additionally, Electron includes a preload script—a JavaScript file that runs before the renderer process loads—that can expose specific APIs from the main process to the renderer in a controlled manner. Understanding this architecture is foundational because every decision you make—from file I/O to window management—will involve these processes. For instance, if you need to read a file from disk and display its contents in the UI, you must send an IPC message from the renderer to the main process, which performs the file operation and sends the data back. This separation prevents the UI thread from blocking and maintains security boundaries.
Step 1: Setting Up Your Development Environment
To begin building with Electron, you need Node.js installed on your machine. Visit nodejs.org and download the LTS version. Once installed, open a terminal or command prompt and verify the installation with node -v and npm -v. You should see version numbers. Next, create a new directory for your project and navigate into it:
mkdir my-electron-app
cd my-electron-app
Initialise a new npm project by running npm init -y. This creates a package.json file. Now install Electron as a development dependency:
npm install electron --save-dev
This command will download the Electron binaries. Depending on your operating system, this may take a few minutes. After installation, open the package.json file and modify the "main" field to point to your main process file. Create that file by adding a "main": "main.js" entry. Also update the "scripts" section to include a start command: "start": "electron .". Your package.json should look like this:
{
"name": "my-electron-app",
"version": "1.0.0",
"description": "",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^28.0.0"
}
}
Now create the main process file, main.js, in your project root. This file will create a BrowserWindow and load an HTML file. Start with this minimal code:
const { app, BrowserWindow } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true
}
});
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();
});
Create an index.html file with a simple “Hello Electron” message, and a preload.js file (even if empty). Run npm start to see your first Electron window. Congratulations—you have a working desktop app!
Step 2: Structuring a Real-World Electron Application
As your application grows, keeping everything in a single directory becomes unmanageable. A well-organised project structure is essential for maintainability, especially when you have multiple windows, services, and assets. A common pattern is to separate your main process code, renderer code, and preload scripts into distinct folders. Here is a recommended structure:
package.jsonmain.js– entry point for main processpreload.js– preload script for renderersrc/– main source codemain/– main process modules (e.g., menu, updater, IPC handlers)renderer/– renderer code (HTML, CSS, JS)preload/– any additional preload scriptsshared/– code used by both main and renderer (constants, types)
assets/– images, fonts, iconsbuild/– build configurationdist/– packaged application output
In the renderer folder, you may want to use a modern front-end framework like React, Vue, or Angular. Electron does not force you to use any particular library, but integrating a framework can streamline UI development. For example, if you use React, set up your project with create-react-app and then modify the main.js to load your React app’s build/index.html. You can also use Electron Forge or Electron Builder to scaffold a project with built-in tooling. I highly recommend using Electron Forge (npm init electron-app@latest my-app -- --template=vite) for new projects because it provides hot reloading, packaging, and publishing out of the box. However, for this tutorial, we stick with manual setup to give you a deeper understanding.
When structuring your project, pay attention to how you handle IPC calls. Create separate files for each major feature (e.g., src/main/file-handlers.js, src/main/database.js) and use a central ipc-main.js to register all listeners. In the renderer, create a utility module that wraps ipcRenderer calls. This separation makes your code testable and scalable.
Step 3: Implementing Inter-Process Communication (IPC)
IPC is the backbone of any Electron application. Without it, your renderer cannot request data from the system or access Node.js APIs. Electron provides two IPC modules: ipcMain for the main process and ipcRenderer for the renderer. The typical communication pattern is request-response or one-way messages. You can use the invoke/handle pattern for request-response, which returns a Promise. Let’s implement a simple example: reading a file from disk when the user clicks a button.
First, in your preload script (preload.js), expose a safe API to the renderer using contextBridge. This is a secure method that prevents the renderer from directly accessing Node.js:
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (filePath) = ipcRenderer.invoke('read-file', filePath)
});
Now in the main process, register a handler for the read-file channel:
const { ipcMain, dialog } = require('electron');
const fs = require('fs');
ipcMain.handle('read-file', async (event, filePath) => {
try {
const data = await fs.promises.readFile(filePath, 'utf-8');
return { success: true, data };
} catch (err) {
return { success: false, error: err.message };
}
});
In your renderer HTML/JS, you can call this function when a button is clicked:
document.getElementById('loadBtn').addEventListener('click', async () => {
const result = await window.electronAPI.readFile('/path/to/file.txt');
if (result.success) {
document.getElementById('output').textContent = result.data;
} else {
console.error('Error:', result.error);
}
});
Always use the preload script with contextIsolation: true and nodeIntegration: false to prevent security vulnerabilities. Never use enableRemoteModule or disable contextIsolation unless you fully understand the risks. Modern Electron (v12+) defaults to these secure settings. Also note that the remote module has been deprecated; use IPC instead.
For one-way messages, use send/on patterns. For example, you might want to notify the main process when the user changes a setting. The same security principles apply.
Step 4: Creating Native Menus and Dialogues
A desktop application without a native menu feels incomplete. Electron makes it easy to create custom application menus using the Menu module. Typically, you build the menu in the main process and set it as the application menu. You can also create context menus for specific elements in the renderer. Let’s walk through creating a standard menu with File, Edit, and Help submenus.
In your main.js, after the app is ready, create the menu template:
const { Menu } = require('electron');
const template = [
{
label: 'File',
submenu: [
{ role: 'quit' }
]
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' }
]
},
{
label: 'Help',
submenu: [
{
label: 'About',
click: () => {
dialog.showMessageBox({ type: 'info', message: 'My Electron App v1.0' });
}
}
]
}
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
For a native look, you can use macOS-specific roles like 'hide' and 'hideOthers'. Electron also supports accelerators (keyboard shortcuts) via the accelerator property. For example, accelerator: 'CmdOrCtrl+S'. To build a context menu (right-click), you need to send an IPC message from the renderer to the main process:
// In preload.js
contextBridge.exposeInMainWorld('electronAPI', {
showContextMenu: () = ipcRenderer.send('show-context-menu')
});
// In main.js
ipcMain.on('show-context-menu', (event) => {
const template = [
{ label: 'Reload', role: 'reload' },
{ label: 'DevTools', role: 'toggleDevTools' }
];
const menu = Menu.buildFromTemplate(template);
menu.popup({ window: BrowserWindow.fromWebContents(event.sender) });
});
In the renderer, attach a contextmenu event listener to the element or document:
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
window.electronAPI.showContextMenu();
});
You can also create custom dialogs (open file, save file, message boxes) using the dialog module. These synchronous dialogs block the renderer, so always call them from the main process via IPC. For example, to let the user select a file:
ipcMain.handle('open-file-dialog', async (event) => {
const result = await dialog.showOpenDialog({ properties: ['openFile'] });
if (!result.canceled) {
return result.filePaths[0];
}
return null;
});
Step 5: Packaging and Distributing Your Application
Once your application is ready, you need to package it into an executable that users can install and run without requiring Node.js. Electron does not include a built-in packager, but there are excellent third-party tools: electron-builder and Electron Forge. In this guide, we will use electron-builder because it offers great flexibility for Windows, macOS, and Linux packaging.
First, install electron-builder as a dev dependency:
npm install electron-builder --save-dev
Then, add a "build" configuration to your package.json. At minimum, you need to specify the app ID, product name, and icon paths. Here is a sample configuration that works for all platforms:
{
"build": {
"appId": "com.example.my-electron-app",
"productName": "MyElectronApp",
"directories": {
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"index.html",
"src/**/*"
],
"win": {
"icon": "assets/icon.ico",
"target": "nsis"
},
"mac": {
"icon": "assets/icon.icns",
"target": "dmg"
},
"linux": {
"icon": "assets/icon.png",
"target": "AppImage"
}
}
}
Add a build script to your package.json scripts section: "build": "electron-builder". Also ensure you have an icon in the specified formats. You can generate icons from a PNG using online tools or the electron-icon-maker package. Run npm run build. Electron-builder will create installers in the dist/ folder. For Windows, you get an .exe (NSIS installer); for macOS, a .dmg; for Linux, an .AppImage. You can further customise options like auto-update, code signing, and publishing to GitHub Releases or the Mac App Store.
For auto-updates, integrate electron-updater (by the electron-builder team). It checks a remote server for new versions and downloads them transparently. This is essential for production apps.
Best Practices and Tips for Electron Development
1. Always Enable Context Isolation and Disable Node Integration
Security should be your top priority. Never use nodeIntegration: true in webPreferences. Instead, use a preload script with contextBridge to expose only the APIs you need. This prevents malicious scripts from accessing Node.js. Also, set webSecurity: true and consider using Content-Security-Policy headers in your HTML to mitigate XSS attacks.
2. Keep the Main Process Lightweight
The main process is single-threaded and blocks the UI if you perform heavy synchronous operations. Offload CPU-intensive tasks to a separate Node.js child process or use worker threads. For I/O operations, always use asynchronous APIs (e.g., fs.promises). If you must perform a long task, consider sending progress updates back to the renderer via IPC.
3. Use BrowserWindow Wisely to Manage Memory
Each window consumes a separate Chromium renderer process, which can be memory-heavy. For performance, limit the number of open windows. If you need a persistent background process, consider using a hidden BrowserWindow or a ServiceWorker (Chromium supports it). Also, set backgroundThrottling: false if your app needs to continue running when minimised. Monitor memory usage with the Chrome Dev Tools (accessible via win.webContents.openDevTools()).
4. Implement a Splash Screen for Slow Startups
Electron apps can take a moment to initialise, especially on first launch. Show a simple splash window (a BrowserWindow with no frame and a logo) while your main window loads. After the main window’s did-finish-load event, close the splash and show the main window.
5. Use the Right Package for Notifications and Tray Icons
To create a system tray icon, use Electron’s Tray module. For native notifications, use Notification API in the renderer (if permission granted) or the Notification module in the main process. Both work across platforms.
FAQ: Frequently Asked Questions About Electron
Q1: Is Electron good for building desktop apps? What are its limitations?
Electron is excellent for apps that need to run on multiple platforms with a web-based UI. Its main limitation is resource usage: each Electron app bundles a full Chromium engine, making the app size large (typically 100-200 MB) and memory consumption higher than native apps. However, for many use cases (like code editors, chat apps, productivity tools), the trade-off is acceptable. Recent improvements like --disable-gpu-sandbox and GPU process management help reduce footprint.
Q2: Can I use web frameworks like React or Vue with Electron?
Absolutely. Electron is just a wrapper around Chromium, so any front-end framework works. You can set up a build pipeline (Webpack, Vite) that outputs static files to your renderer folder. Many developers use electron-vite for seamless integration. Just ensure that your framework’s development server runs on a local port and you load that URL in Electron during development, then switch to loading the built files for production.
Q3: How do I handle file system access securely?
Expose only specific file operations through your preload script. Never let the renderer receive arbitrary file paths from user input. Use dialog.showOpenDialog to get file paths from the user, then validate them in the main process. Use absolute paths and restrict directories if possible. For reading application data (like user preferences), use Electron’s app.getPath('userData') to avoid path traversal vulnerabilities.
Q4: What is the best way to manage multiple windows in Electron?
Track your window instances in an array or a Map. Use the win.on('closed') event to remove references (avoid memory leaks). For window-to-window communication, use the main process as a relay: both windows send IPC messages to the main process, which forwards them to the other window. Alternatively, you can use webContents.send() on the target window. Avoid sharing renderer processes.
Q5: How do I implement auto-updates in Electron?
Use the electron-updater package from the electron-builder ecosystem. Set up a server (like GitHub Releases, S3, or your own) that hosts the latest installer. In your main process:
const { autoUpdater } = require('electron-updater');
autoUpdater.checkForUpdatesAndNotify();
autoUpdater.on('update-available', () => {
// Notify user
});
autoUpdater.on('download-progress', (progress) => {
// Update progress bar
});
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});
You need to sign your builds for macOS and set the publisher for Windows to avoid SmartScreen warnings.
Q6: Can I access native Node.js modules (like native addons) in Electron?
Yes, but they must be compiled against the exact Electron Node.js version. Use electron-rebuild (part of the Electron ecosystem) to recompile native modules automatically. Add it as a postinstall script in package.json.
Conclusion: What’s Next?
Electron empowers you to create desktop applications with the tools you already know—JavaScript, HTML, and CSS. This tutorial covered the essential steps: setting up the environment, understanding the two-process architecture, structuring a real project, implementing IPC, building native menus, and packaging for distribution. You also learned critical security and performance best practices. With this foundation, you can now explore advanced features like custom title bars, multi-threading via worker threads, WebGPU acceleration, and integration with native APIs (Notification, Tray, PowerMonitor). The Electron ecosystem is vast, with libraries for everything from SQLite databases to automatic updates. As you build, remember to profile your app’s performance using Chromium DevTools and always test on all target platforms. The future of desktop development is hybrid, and Electron is a robust choice. Now go build something amazing.
Reference Tables
| Feature | Electron Forge | Electron Builder |
|---|---|---|
| Init CLI | Yes (includes templates) | No (manual setup) |
| Auto-updates | Built-in via electron-forge | Via electron-updater (separate package) |
| Code signing | Manual configuration | Built-in support for macOS and Windows |
| Target formats | DMG, NSIS, AppImage, Snap, etc. | Same plus .pkg, .msi, deb, rpm |
| Hot reload | Yes (with Vite/Webpack plugins) | Not built-in (use third-party) |
| Ease of use | Beginner friendly | More control, steeper learning curve |
| Pattern | Main Process API | Renderer Process API | Use Case |
|---|---|---|---|
| Request-Response | ipcMain.handle(channel, handler) | ipcRenderer.invoke(channel, …args) | Read file, database query |
| One-way (main to renderer) | win.webContents.send(channel, data) | ipcRenderer.on(channel, listener) | Menu command, system event |
| One-way (renderer to main) | ipcMain.on(channel, listener) | ipcRenderer.send(channel, data) | Logging, UI state change |
| Broadcast (main to all windows) | BrowserWindow.getAllWindows().forEach(w => w.webContents.send(…)) | Same as above | Global notifications, theme changes |