Back to Home

Electron Guide - Cross-Platform Desktop Apps

Architecture Deep Dive

Why does Electron split your app into multiple processes? How does it prevent security vulnerabilities? And what patterns actually work in production?

This chapter explains the why behind Electron's architecture decisions. Understanding these constraints will help you design better apps—because you'll know when to fight the framework and when to work with it.

Why Multi-Process? Chrome's Hard-Learned Lessons

In the early 2000s, browsers crashed constantly. One bad tab could kill your entire browser session. Chrome solved this with multi-process architecture: each tab runs in isolation. Electron inherited this design—not for performance, but for security and stability.

Here's the trade-off: you get security boundaries and crash isolation, but you can't directly share data between processes. Everything goes through IPC channels. This seems annoying at first, but it's what prevents a single XSS vulnerability from accessing your users' file systems.

🎯 Real-World Impact (VibeBlaster)

VibeBlaster's UI displays user-generated content (tweets, bios, etc.). If I loaded that directly in a renderer with Node.js access, a malicious tweet with JavaScript could access OAuth tokens stored on disk. Multi-process architecture prevents this: the renderer is sandboxed, and only the main process touches the file system.

Result: Even if someone finds an XSS vulnerability, they're trapped in a Chromium sandbox with no filesystem access.

Main Process(Node.js + Electron)App LifecycleWindow ManagementNative APIsIPC MainFile SystemRenderer 1(Chromium + Preload)HTML/CSS/JSIPC RendererRenderer 2(Chromium + Preload)HTML/CSS/JSIPC RendererIPCOperatingSystemFile SystemNative APIsSystem TrayNotifications

Main Process

  • • Runs Node.js + Electron APIs
  • • One per application
  • • Manages app lifecycle & windows
  • • Full access to Node.js & OS
  • • Entry point: main.js

Renderer Process

  • • Runs Chromium + Web APIs
  • • One per BrowserWindow
  • • Displays UI (HTML/CSS/JS)
  • • Sandboxed by default
  • • Entry point: index.html

The Key Insight: Trust Boundaries

The main process is trusted—it can do anything (read files, make network requests, spawn processes). Renderer processes are untrusted—they might be displaying user content, executing third-party code, or loading remote websites.

This is why IPC exists: it's the checkpoint where you validate requests from untrusted renderers before allowing trusted main process operations. Think of it like an API gateway between your frontend and backend.

IPC: Your App's Internal API

If the main process is your backend and renderers are your frontend, IPC (Inter-Process Communication) is your HTTP. It's how you request data, send commands, and push updates across process boundaries.

Unlike HTTP, IPC is synchronous by default and extremely fast (microseconds, not milliseconds). But the patterns are familiar: request/response, fire-and-forget, and server push.

The Three IPC Patterns

1

Request-Response (invoke/handle)

Renderer requests data, waits for main process response. Async, returns promise.

Example: await window.api.loadSettings() → Main reads file → Returns data

2

Fire-and-Forget (send/on)

Renderer sends message, doesn't wait for response. One-way communication.

Example: window.api.logAnalytics(event) → Main logs event → No response

3

Server Push (webContents.send)

Main process sends updates to renderer without being asked.

Example: Background job completes → Main sends 'job-complete' → Renderer shows notification

When to Use Each Pattern

💬

Use invoke/handle (Request-Response) when:

  • • You need data back from the main process (read files, database queries)
  • • You want to await the result (wait for OAuth flow to complete)
  • • You need error handling (catch exceptions from main process)

VibeBlaster example: await window.api.connectTwitter()opens OAuth window, waits for callback, returns success/failure.

📤

Use send/on (Fire-and-Forget) when:

  • • You don't care about the result (logging, analytics)
  • • You want maximum performance (no waiting for response)
  • • The operation is best-effort (telemetry, usage tracking)

VibeBlaster example: window.api.logInteraction('tweet_click')fires telemetry event, doesn't wait for confirmation.

📥

Use webContents.send (Server Push) when:

  • • Main process detects changes (file watcher, background sync)
  • • Long-running operations complete (download finishes, job processes)
  • • You need to broadcast to multiple windows

VibeBlaster example: Background job detects new mentions → Main sends 'new-notifications' → Renderer updates badge count.

⚠️ Common Mistake: Using send/on When You Need invoke/handle

Beginners often use send/on because it's simpler, then manually implement response handling by sending another message back. This creates race conditions and complex state management.

Rule: If you need a response, use invoke/handle. It handles promises, errors, and timeouts automatically.

Security: Why the Defaults Matter

Electron apps have historically been targets for security exploits because early apps disabled security features for convenience. The defaults have changed—now security is opt-out, not opt-in. Understandingwhy these defaults exist will help you avoid disabling them.

🚨 Never Do This in Production:

webPreferences: { nodeIntegration: true, // ❌ DANGER contextIsolation: false, // ❌ DANGER }

This combination gives your renderer process full Node.js access. If you load any untrusted content (user input, external APIs, third-party libraries), an attacker can execute arbitrary code on the user's machine. This includes reading files, installing malware, or stealing credentials.

The Three Security Pillars

1

Context Isolation (contextIsolation: true)

Separates your preload script from the renderer's JavaScript context. Even if an XSS attack injects malicious JavaScript into your renderer, it can't access Electron or Node.js APIs exposed by your preload script.

How it works:

Your preload script runs in a separate V8 context. You use contextBridgeto explicitly expose APIs to window. Malicious code can't redefine or access your preload scope.

2

Node Integration Disabled (nodeIntegration: false)

Prevents the renderer from using require()to import Node.js modules. Your renderer is just a web page—no filesystem access, no child process spawning.

The threat model:

Without this, a simple <script>require("child_process").exec("rm -rf /")</script>in user-generated content could wipe the user's drive. Context isolation alone isn't enough—you need to prevent Node.js access entirely.

3

Sandbox Mode (sandbox: true)

Runs renderer processes in Chromium's sandbox—the same isolation used in Chrome. Even if an attacker exploits a Chromium vulnerability, they're still trapped in the sandbox with no OS access.

Trade-off:

Sandbox mode disables Node.js in preload scripts. You'll need to communicate with the main process via IPC for everything. This is more secure but requires more boilerplate. VibeBlaster uses sandbox mode for maximum security.

🛡️ Secure Configuration (Copy This)

new BrowserWindow({
  webPreferences: {
    preload: path.join(__dirname, 'preload.js'),
    contextIsolation: true,   // ✅ Isolate contexts
    nodeIntegration: false,   // ✅ No Node.js in renderer
    sandbox: true,            // ✅ Chromium sandbox
    webSecurity: true,        // ✅ Enforce same-origin policy
    allowRunningInsecureContent: false,  // ✅ No mixed content
  }
});

This is the default configuration in Electron 20+. Don't change it unless you have a very good reason.

💡 Real-World Lesson (VibeBlaster)

Early versions of VibeBlaster had nodeIntegration: truebecause it made OAuth flows easier to implement. During a security audit, we realized a malicious browser extension could inject JavaScript into the renderer and access OAuth tokens stored in localStorage.

Switching to full sandboxing required rewriting OAuth to use IPC channels and storing tokens in the main process (encrypted via safeStorage). It took two weeks, but now even a compromised renderer can't steal credentials.

Lesson: Pay the complexity cost early. Retrofitting security is exponentially harder than building it in from day one.

Architectural Patterns That Work

These aren't theoretical best practices—they're lessons learned from shipping production Electron apps. Follow them to avoid the mistakes I made with VibeBlaster.

Type your IPC channels

Use TypeScript interfaces for channel names and payloads. Catch mistakes at compile time, not runtime.

Validate IPC inputs

Treat renderer requests like untrusted HTTP requests. Validate types, check bounds, sanitize file paths.

Keep main process lean

Heavy computation blocks the UI. Use worker threads or spawn background processes for intensive tasks.

Implement timeouts

Don't let renderer wait forever. Timeout long-running IPC handlers and return errors gracefully.

Error boundaries

Wrap IPC handlers in try/catch. Log errors, return user-friendly messages, don't crash the main process.

Never share memory

Use IPC for all communication. SharedArrayBuffer exists but breaks security boundaries—avoid it.

You Understand the Foundation

You now know why Electron works the way it does: security boundaries, IPC patterns, and the trade-offs between convenience and safety. This mental model will guide every architectural decision you make.

Next up: Building actual UI. You'll learn how to integrate React, set up routing, implement drag-and-drop, and handle native menus—all while maintaining the security boundaries we just covered.

Spoiler: Your UI code is just web development. The interesting part is bridging it to native features via IPC.