Frameworks

Building Desktop Apps with Electron: From Web Developer to Desktop Developer

Building Desktop Apps with Electron: From Web Developer to Desktop Developer

If you’ve ever wished your web application could escape the browser and live on a user’s desktop — with native menus, system tray icons, file system access, and offline capabilities — Electron is the framework that makes it happen. Built on Chromium and Node.js, Electron lets you leverage your existing HTML, CSS, and JavaScript skills to create fully functional cross-platform desktop applications for Windows, macOS, and Linux.

This isn’t just a theoretical possibility. Some of the most widely used desktop applications in the world are built with Electron: Visual Studio Code, Slack, Discord, Figma’s desktop app, and Notion all rely on this framework. In this guide, we’ll walk through everything you need to know to go from web developer to desktop developer using Electron — from project setup and architecture to advanced features like IPC communication, auto-updates, and system tray integration.

Why Electron for Desktop Development?

Before diving into code, it’s worth understanding why Electron has become the go-to choice for so many development teams. The core value proposition is straightforward: you write one codebase and ship to three operating systems. But the benefits go deeper than that.

First, there’s the talent pool advantage. Finding developers who know HTML, CSS, and JavaScript is dramatically easier than finding specialists in Qt, WPF, or Cocoa. If your team already builds web applications — especially with frameworks like React, Vue, or Angular (see our comparison of React, Vue, and Svelte) — the transition to Electron development is remarkably smooth.

Second, Electron gives you full access to the Node.js ecosystem. Every npm package you’ve ever used is available in your desktop app. Need to interact with a database? Use the same libraries you’d use in a server application. Need to process files? Node.js has robust file system APIs. This combination of browser-grade rendering with server-grade system access is what makes Electron uniquely powerful.

Third, the update cycle is fast. Unlike native applications that require platform-specific build pipelines and app store reviews, Electron apps can be distributed and updated directly. You maintain control over your release cadence.

The trade-offs are real, though. Electron apps bundle a full Chromium instance, so they consume more memory and disk space than a truly native application. A minimal Electron app starts at roughly 60-80 MB on disk. For many use cases, this is perfectly acceptable — but it’s a factor worth considering for your specific project requirements.

Prerequisites and Environment Setup

To follow along with this guide, you’ll need Node.js (version 18 or later recommended) and npm installed on your system. If you haven’t set up Node.js yet, our guide to getting started with Node.js covers installation and configuration in detail.

You should also be comfortable with modern JavaScript features like destructuring, async/await, and ES modules. If you need a refresher, check out our overview of ES6+ JavaScript features.

Let’s create a new project from scratch:

mkdir electron-desktop-app
cd electron-desktop-app
npm init -y
npm install electron --save-dev

After installation, your package.json needs a start script. Update it to include:

"scripts": {
  "start": "electron ."
}

Understanding Electron’s Architecture

Electron’s architecture revolves around two types of processes that work together: the main process and renderer processes. Understanding this distinction is fundamental to building robust Electron applications.

The main process runs in a Node.js environment. It manages application lifecycle, creates browser windows, handles native OS interactions (menus, dialogs, system tray), and coordinates communication between renderer processes. There is exactly one main process per application.

Renderer processes are essentially Chromium browser instances. Each window (BrowserWindow) runs its own renderer process with access to web APIs — the DOM, Canvas, WebGL, and everything else you’d expect in a browser. By default, renderer processes do not have direct access to Node.js APIs for security reasons.

The bridge between these two worlds is IPC (Inter-Process Communication) and the preload script. The preload script runs before the renderer’s web content loads and has access to both the DOM and a limited set of Electron/Node.js APIs. It acts as a secure gateway, exposing only the specific functionality your renderer needs.

Building Your First Electron Application

Let’s build a practical application that demonstrates Electron’s core concepts: a main process that creates a window, a preload script that bridges the gap, and a renderer that provides the user interface. We’ll also implement IPC communication, which is how real Electron apps coordinate between processes.

Code Example 1: Main Process with IPC and Preload Bridge

// main.js — The main process entry point
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    minWidth: 800,
    minHeight: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,   // Security: isolate renderer context
      nodeIntegration: false,   // Security: no Node.js in renderer
      sandbox: true             // Security: sandbox renderer process
    },
    titleBarStyle: 'hiddenInset', // macOS: integrate with title bar
    show: false                   // Prevent visual flash on load
  });

  mainWindow.loadFile('index.html');

  // Show window only after content is ready (prevents white flash)
  mainWindow.once('ready-to-show', () => {
    mainWindow.show();
  });
}

// IPC Handler: Open a file dialog and read file contents
ipcMain.handle('dialog:openFile', async () => {
  const result = await dialog.showOpenDialog(mainWindow, {
    properties: ['openFile'],
    filters: [
      { name: 'Text Files', extensions: ['txt', 'md', 'json', 'js'] },
      { name: 'All Files', extensions: ['*'] }
    ]
  });

  if (result.canceled || result.filePaths.length === 0) {
    return { success: false, reason: 'cancelled' };
  }

  const filePath = result.filePaths[0];
  const content = await fs.promises.readFile(filePath, 'utf-8');
  return {
    success: true,
    filePath,
    fileName: path.basename(filePath),
    content
  };
});

// IPC Handler: Save content to a file
ipcMain.handle('dialog:saveFile', async (event, { content, defaultName }) => {
  const result = await dialog.showSaveDialog(mainWindow, {
    defaultPath: defaultName || 'untitled.txt',
    filters: [
      { name: 'Text Files', extensions: ['txt', 'md'] },
      { name: 'All Files', extensions: ['*'] }
    ]
  });

  if (result.canceled) {
    return { success: false, reason: 'cancelled' };
  }

  await fs.promises.writeFile(result.filePath, content, 'utf-8');
  return { success: true, filePath: result.filePath };
});

// IPC Handler: Get system information
ipcMain.handle('system:getInfo', () => {
  return {
    platform: process.platform,
    arch: process.arch,
    nodeVersion: process.versions.node,
    electronVersion: process.versions.electron,
    chromeVersion: process.versions.chrome
  };
});

// --- preload.js — Secure bridge between main and renderer ---
// This file should be saved separately as preload.js
//
// const { contextBridge, ipcRenderer } = require('electron');
//
// contextBridge.exposeInMainWorld('electronAPI', {
//   openFile: () => ipcRenderer.invoke('dialog:openFile'),
//   saveFile: (data) => ipcRenderer.invoke('dialog:saveFile', data),
//   getSystemInfo: () => ipcRenderer.invoke('system:getInfo'),
//
//   // One-way communication: renderer to main
//   sendNotification: (message) =>
//     ipcRenderer.send('notification:show', message),
//
//   // Listen for messages from main process
//   onUpdateAvailable: (callback) =>
//     ipcRenderer.on('update:available', (event, info) =>
//       callback(info)),
// });

app.whenReady().then(() => {
  createWindow();

  // macOS: re-create window when dock icon is clicked
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

// Quit when all windows are closed (except on macOS)
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

This setup follows Electron’s security best practices: contextIsolation is enabled, nodeIntegration is disabled, and the renderer is sandboxed. The preload script (shown in comments for clarity) uses contextBridge to expose only specific functions to the renderer — never raw IPC access. For more on web application security principles, see our OWASP Top 10 security guide.

In the renderer (your index.html and associated scripts), you’d call these exposed functions like this:

// In your renderer script (loaded by index.html)
document.getElementById('open-btn').addEventListener('click', async () => {
  const result = await window.electronAPI.openFile();
  if (result.success) {
    document.getElementById('editor').value = result.content;
    document.title = result.fileName;
  }
});

The pattern here — invoke from renderer, handle in main, return result — is the backbone of all Electron applications. Master this, and you can build anything.

Advanced Features: Auto-Updater and System Tray

Production Electron apps need more than windows and dialogs. Two features that users expect from desktop software are automatic updates and system tray integration. Let’s implement both.

Code Example 2: Auto-Updater with System Tray Icon

// updater-and-tray.js — Production features module
const { app, Tray, Menu, nativeImage, Notification,
        BrowserWindow } = require('electron');
const { autoUpdater } = require('electron-updater');
const path = require('path');
const log = require('electron-log');

let tray = null;

// ============================================
// AUTO-UPDATER CONFIGURATION
// ============================================
function initAutoUpdater(mainWindow) {
  // Configure logging for debugging update issues
  autoUpdater.logger = log;
  autoUpdater.logger.transports.file.level = 'info';

  // Check for updates every 4 hours
  const UPDATE_INTERVAL = 4 * 60 * 60 * 1000;

  autoUpdater.on('checking-for-update', () => {
    log.info('Checking for updates...');
    mainWindow.webContents.send('update:status', {
      status: 'checking'
    });
  });

  autoUpdater.on('update-available', (info) => {
    log.info('Update available:', info.version);

    // Notify the renderer process
    mainWindow.webContents.send('update:available', {
      version: info.version,
      releaseDate: info.releaseDate,
      releaseNotes: info.releaseNotes
    });

    // Show system notification
    new Notification({
      title: 'Update Available',
      body: `Version ${info.version} is ready to download.`,
      icon: path.join(__dirname, 'assets', 'icon.png')
    }).show();
  });

  autoUpdater.on('download-progress', (progress) => {
    mainWindow.webContents.send('update:progress', {
      percent: Math.round(progress.percent),
      transferred: progress.transferred,
      total: progress.total,
      bytesPerSecond: progress.bytesPerSecond
    });

    // Update taskbar progress (Windows) / dock badge (macOS)
    mainWindow.setProgressBar(progress.percent / 100);
  });

  autoUpdater.on('update-downloaded', (info) => {
    log.info('Update downloaded:', info.version);
    mainWindow.setProgressBar(-1); // Remove progress bar

    mainWindow.webContents.send('update:ready', {
      version: info.version
    });

    // Update tray menu to show "Restart to Update" option
    updateTrayMenu(mainWindow, true, info.version);
  });

  autoUpdater.on('error', (err) => {
    log.error('Auto-updater error:', err);
    mainWindow.setProgressBar(-1);
  });

  // Initial check after 10-second delay (let app finish loading)
  setTimeout(() => autoUpdater.checkForUpdates(), 10000);

  // Periodic checks
  setInterval(() => autoUpdater.checkForUpdates(), UPDATE_INTERVAL);
}

// ============================================
// SYSTEM TRAY CONFIGURATION
// ============================================
function initTray(mainWindow) {
  // Create tray icon (use @2x for Retina displays on macOS)
  const iconPath = process.platform === 'darwin'
    ? path.join(__dirname, 'assets', 'tray-iconTemplate.png')
    : path.join(__dirname, 'assets', 'tray-icon.png');

  const icon = nativeImage.createFromPath(iconPath);

  // On macOS, use 16x16 template images for menu bar
  if (process.platform === 'darwin') {
    icon.setTemplateImage(true);
  }

  tray = new Tray(icon);
  tray.setToolTip('My Electron App');

  updateTrayMenu(mainWindow, false, null);

  // Click behavior varies by platform
  tray.on('click', () => {
    if (process.platform === 'darwin') {
      // macOS: toggle window visibility
      mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
    } else {
      // Windows/Linux: show and focus window
      mainWindow.show();
      mainWindow.focus();
    }
  });

  // Double-click to open (Windows convention)
  tray.on('double-click', () => {
    mainWindow.show();
    mainWindow.focus();
  });
}

function updateTrayMenu(mainWindow, updateReady, newVersion) {
  const contextMenu = Menu.buildFromTemplate([
    {
      label: 'Show App',
      click: () => {
        mainWindow.show();
        mainWindow.focus();
      }
    },
    { type: 'separator' },
    {
      label: 'Start Minimized on Login',
      type: 'checkbox',
      checked: app.getLoginItemSettings().openAtLogin,
      click: (menuItem) => {
        app.setLoginItemSettings({
          openAtLogin: menuItem.checked,
          openAsHidden: true
        });
      }
    },
    { type: 'separator' },
    ...(updateReady ? [{
      label: `Restart to Update (v${newVersion})`,
      click: () => autoUpdater.quitAndInstall()
    }, { type: 'separator' }] : []),
    {
      label: 'Quit',
      click: () => {
        app.isQuitting = true;
        app.quit();
      }
    }
  ]);

  tray.setContextMenu(contextMenu);
}

// Prevent app from fully closing — minimize to tray instead
function setupCloseToTray(mainWindow) {
  mainWindow.on('close', (event) => {
    if (!app.isQuitting) {
      event.preventDefault();
      mainWindow.hide();

      // Show notification on first minimize (one-time)
      if (!app.minimizedOnce) {
        new Notification({
          title: 'Still Running',
          body: 'The app has been minimized to the system tray.',
          silent: true
        }).show();
        app.minimizedOnce = true;
      }
    }
  });
}

module.exports = { initAutoUpdater, initTray, setupCloseToTray };

This code demonstrates several production-ready patterns. The auto-updater uses electron-updater (from the electron-builder ecosystem) to check for updates, download them in the background, and prompt the user to restart. The system tray integration provides a persistent presence on the user’s desktop, with platform-appropriate behavior for macOS, Windows, and Linux.

Project Structure for Scalable Apps

As your Electron application grows, a well-organized project structure becomes critical. Here’s a battle-tested layout that separates concerns cleanly:

my-electron-app/
├── src/
│   ├── main/              # Main process code
│   │   ├── main.js        # Entry point
│   │   ├── menu.js        # Application menu
│   │   ├── updater.js     # Auto-update logic
│   │   └── ipc-handlers/  # IPC handler modules
│   ├── preload/
│   │   └── preload.js     # Context bridge
│   └── renderer/          # Frontend (React/Vue/vanilla)
│       ├── index.html
│       ├── styles/
│       └── scripts/
├── assets/                # Icons, images
├── build/                 # Build configuration
├── dist/                  # Compiled output
└── package.json

For the renderer, you can use any frontend framework or even plain HTML/CSS/JavaScript. Many teams use React or Vue for complex UIs. If you’re bundling your renderer code, tools like webpack or Vite handle the compilation step — our webpack fundamentals guide covers the bundling concepts that apply here.

Packaging and Distribution

Once your app is built, you need to package it for distribution. The two main tools are electron-builder and electron-forge. Both handle the complexity of creating platform-specific installers — .dmg for macOS, .exe/.msi for Windows, .deb/.AppImage for Linux.

Here’s a typical electron-builder configuration in package.json:

"build": {
  "appId": "com.yourcompany.yourapp",
  "productName": "Your App",
  "directories": {
    "output": "dist"
  },
  "mac": {
    "target": ["dmg", "zip"],
    "category": "public.app-category.developer-tools"
  },
  "win": {
    "target": ["nsis", "portable"]
  },
  "linux": {
    "target": ["AppImage", "deb"],
    "category": "Development"
  },
  "publish": {
    "provider": "github",
    "owner": "your-username",
    "repo": "your-repo"
  }
}

Automating your build pipeline with CI/CD is essential for consistent releases. You can configure GitHub Actions to build and publish releases for all three platforms from a single push — our GitHub Actions CI/CD guide walks through the setup process.

Performance Optimization

Electron apps have a reputation for being resource-heavy, but much of that comes from poor optimization rather than inherent framework limitations. Here are proven techniques to keep your app fast and lean.

Startup Time

Cold startup is where users form their first impression. Delay loading non-essential modules using dynamic require() or import(). Show the window immediately with a loading state rather than waiting for all data to load. Use the ready-to-show event (as shown in our first code example) to prevent the white flash that makes apps feel sluggish.

Memory Management

Monitor memory consumption with process.memoryUsage() in the main process and performance.memory in the renderer. Dispose of large objects when they’re no longer needed. If your app creates multiple windows, be aware that each BrowserWindow is a separate Chromium renderer process with its own memory footprint. For detailed techniques on keeping applications performant, see our web performance optimization guide.

Reducing Bundle Size

Use tools like electron-builder‘s files configuration to exclude development dependencies, test files, and documentation from the packaged application. Consider using ASAR archives (enabled by default in electron-builder) to package your app files efficiently.

Security Best Practices

Security in Electron deserves careful attention because your app has access to both web content and the operating system. Follow these principles:

  • Always enable contextIsolation — this prevents the renderer from directly accessing Electron or Node.js internals.
  • Never enable nodeIntegration in renderer processes — use the preload script with contextBridge instead.
  • Validate all IPC messages — treat data from the renderer as untrusted input, just as you would with HTTP requests on a server.
  • Use the sandbox option — this further restricts what the renderer process can do.
  • Be cautious with shell.openExternal() — always validate URLs before opening them to prevent command injection.
  • Keep Electron updated — Chromium vulnerabilities are patched regularly, and staying current is critical.

Adding TypeScript to your Electron project is another way to improve security and code quality. Type checking catches entire categories of bugs at compile time rather than runtime, including the kind of type confusion bugs that can lead to security vulnerabilities.

Testing Electron Applications

Testing desktop apps introduces challenges beyond typical web testing. You need to verify both the UI behavior and the native OS integrations.

For unit testing your main process logic, standard testing frameworks like Jest or Vitest work well. Mock Electron’s APIs to test IPC handlers, menu builders, and update logic in isolation.

For end-to-end testing, Playwright has first-class Electron support. It can launch your app, interact with windows, and verify behavior:

const { _electron: electron } = require('playwright');

test('app launches and shows main window', async () => {
  const app = await electron.launch({ args: ['.'] });
  const window = await app.firstWindow();
  const title = await window.title();
  expect(title).toBe('My Electron App');
  await app.close();
});

For teams managing complex cross-platform projects, tools like Taskee can help organize testing matrices across operating systems and coordinate between development, QA, and release workflows.

Real-World Architecture Patterns

As your Electron app matures, you’ll encounter architectural decisions that don’t arise in web development. Here are patterns that production apps commonly adopt.

Background Workers

CPU-intensive tasks (file processing, data transformation, compression) should never run in the main or renderer process — they’ll freeze the UI. Use hidden BrowserWindows or Node.js worker_threads to offload heavy computation.

Persistent Storage

For configuration and small data sets, electron-store provides a simple key-value store that persists to disk. For more structured data, SQLite (via better-sqlite3) gives you a full relational database embedded in your app — no server required.

Native Module Integration

Sometimes you need functionality that pure JavaScript can’t provide. Electron supports native Node.js addons written in C/C++ via N-API. The electron-rebuild tool ensures native modules are compiled against the correct Electron headers.

Multi-Window Communication

When your app has multiple windows (e.g., a main window and a preferences panel), coordinate them through the main process. Each window can send messages to the main process via IPC, and the main process can forward messages to other windows using webContents.send().

Electron Alternatives and When to Choose Them

Electron isn’t the only option for cross-platform desktop development, and it’s worth understanding the landscape.

Tauri uses the operating system’s built-in webview instead of bundling Chromium, resulting in dramatically smaller binaries (often under 10 MB). The backend is written in Rust, which provides excellent performance but requires learning a new language. Tauri is a strong choice when bundle size is a priority and your UI is relatively simple.

NW.js (formerly node-webkit) is similar to Electron but merges the Node.js and browser contexts. This is simpler for small projects but less secure for larger applications.

Flutter (with desktop support) uses its own rendering engine and the Dart language. It produces truly native-feeling UIs but requires learning Dart and a different development paradigm.

For most web development teams, Electron remains the pragmatic choice. The ecosystem is mature, the documentation is thorough, and the community is large. If your team already builds web apps professionally — perhaps working with a digital agency like Toimi — the skills transfer directly.

Debugging and Developer Experience

One of Electron’s strengths is that you can debug the renderer process using Chrome DevTools — the same tools you already know. Open DevTools programmatically with mainWindow.webContents.openDevTools() or use the keyboard shortcut (Cmd/Ctrl+Shift+I).

For the main process, you can attach a debugger by launching Electron with the --inspect flag:

electron --inspect=5858 .

Then connect Chrome DevTools or VS Code’s debugger to chrome://inspect. This gives you full breakpoint debugging, variable inspection, and profiling for the Node.js side of your application.

The Devtron extension adds Electron-specific debugging panels to DevTools, letting you inspect IPC messages, installed modules, and event listeners.

FAQ

Is Electron suitable for resource-constrained environments or lightweight applications?

Electron adds overhead due to bundling Chromium and Node.js — a minimal app uses around 60-80 MB of disk space and 80-150 MB of RAM. For lightweight utilities or apps targeting low-spec machines, alternatives like Tauri (which uses the system’s native webview) produce significantly smaller binaries. However, for full-featured applications where development speed and web technology compatibility matter, Electron’s overhead is generally acceptable. Most modern desktop computers have sufficient resources to run Electron apps comfortably, and techniques like lazy loading, code splitting, and efficient memory management can minimize the performance impact.

Can I use React, Vue, or other frontend frameworks with Electron?

Yes, and most production Electron apps do exactly this. The renderer process is a full Chromium browser, so any frontend framework that runs in a browser will work in Electron. React, Vue, Svelte, Angular, and even vanilla JavaScript are all valid choices. Tools like Vite and webpack handle the build step for your renderer code. The key consideration is configuring your build tool to output files that Electron can load — typically static HTML/CSS/JS files rather than a dev server, though you can use a dev server during development for hot module replacement.

How do I distribute and update my Electron app?

Use electron-builder or electron-forge to package your app into platform-specific installers (.dmg for macOS, .exe/.msi for Windows, .AppImage/.deb for Linux). For auto-updates, the electron-updater module integrates with GitHub Releases, Amazon S3, or custom servers. You publish new versions to your update source, and the app checks periodically, downloads updates in the background, and prompts users to restart. You can also distribute through platform app stores (Mac App Store, Microsoft Store, Snap Store) though this requires additional configuration and review processes.

What security precautions should I take when building Electron apps?

The most critical security settings are enabling contextIsolation, disabling nodeIntegration, and using the sandbox option in your BrowserWindow configuration. Always use a preload script with contextBridge to expose only the specific APIs your renderer needs — never give the renderer direct access to Node.js or Electron internals. Validate all data received through IPC channels as you would validate HTTP input on a server. Sanitize any user-provided URLs before passing them to shell.openExternal(). Keep Electron updated to patch Chromium security vulnerabilities. If your app loads remote content, implement a strict Content Security Policy and use the session.webRequest API to filter network requests.

How do I handle native OS features like file system access, notifications, and keyboard shortcuts?

Electron provides built-in modules for most native OS features. Use the dialog module for file open/save dialogs, the Notification class for system notifications, globalShortcut for system-wide keyboard shortcuts, and the Menu/Tray modules for native menus and system tray icons. File system access is handled through Node.js fs module in the main process, with results passed to the renderer via IPC. For features that Electron doesn’t cover natively (like Touch Bar on macOS or Windows toast notifications with actions), npm packages extend the functionality. The pattern is always the same: implement the native interaction in the main process, expose it through IPC, and call it from the renderer.

Conclusion

Electron represents a practical bridge between web development and desktop software. It won’t replace truly native development for performance-critical applications, but for the vast majority of desktop tools, productivity apps, and developer utilities, it delivers an excellent balance of development speed, cross-platform reach, and capability.

The path from web developer to desktop developer with Electron is shorter than you might expect. If you can build a web application, you already have 80% of the skills needed. The remaining 20% — understanding the process architecture, implementing IPC, handling native OS features, and configuring packaging — is what this guide has covered.

Start with a simple project: take a web tool you’ve already built and give it a desktop life with Electron. Add file system access, offline support, and native notifications. Once you see your web code running as a real desktop application with its own icon in the dock, you’ll understand why so many teams have chosen this path.