Skip to main content
Technology & EngineeringDesktop App310 lines

Electron Ipc

IPC communication patterns for Electron main and renderer process messaging

Quick Summary27 lines
You are an expert in Electron's Inter-Process Communication (IPC) system for building secure, well-structured communication between main and renderer processes.

## Key Points

1. **Renderer to Main** (one-way): `ipcRenderer.send` / `ipcMain.on`
2. **Renderer to Main with response** (two-way): `ipcRenderer.invoke` / `ipcMain.handle`
3. **Main to Renderer** (push): `webContents.send` / `ipcRenderer.on`
- Always use `ipcMain.handle` / `ipcRenderer.invoke` for request-response patterns. It returns a Promise and handles errors cleanly.
- Whitelist IPC channels in the preload script. Never expose a generic `ipcRenderer.send` without validation.
- Define channel names as shared constants to avoid typos and enable easy refactoring.
- Validate all data received in `ipcMain` handlers. Treat renderer input as untrusted.
- Always remove IPC listeners when they are no longer needed to prevent memory leaks.
- Use structured return types (`{ success, data, error }`) for consistent error handling.
- Keep IPC handlers thin — delegate to service modules for business logic.
- **Exposing `ipcRenderer` directly**: Never do `contextBridge.exposeInMainWorld('ipc', ipcRenderer)`. This gives the renderer full access to all IPC channels.
- **Forgetting to remove listeners**: `ipcRenderer.on` persists across page navigations in the same window. Always return and call cleanup functions.

## Quick Example

```javascript
// renderer.js
document.getElementById('btn-minimize').addEventListener('click', () => {
  window.api.send('window:minimize');
});
```
skilldb get desktop-app-skills/Electron IpcFull skill: 310 lines
Paste into your CLAUDE.md or agent config

Electron IPC — Desktop Apps

You are an expert in Electron's Inter-Process Communication (IPC) system for building secure, well-structured communication between main and renderer processes.

Overview

Electron's IPC (Inter-Process Communication) is the mechanism by which the main process and renderer processes exchange messages. Since renderer processes run in a sandboxed Chromium environment and the main process has full Node.js access, IPC is the only safe way to bridge them. Modern Electron enforces this through contextIsolation and preload scripts that expose a controlled API surface via contextBridge.

Three primary IPC patterns:

  1. Renderer to Main (one-way): ipcRenderer.send / ipcMain.on
  2. Renderer to Main with response (two-way): ipcRenderer.invoke / ipcMain.handle
  3. Main to Renderer (push): webContents.send / ipcRenderer.on

Setup & Configuration

Preload script foundation

// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('api', {
  // Two-way: renderer invokes, main responds
  invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),

  // One-way: renderer fires, main handles
  send: (channel, ...args) => ipcRenderer.send(channel, ...args),

  // Listen for messages from main
  on: (channel, callback) => {
    const subscription = (_event, ...args) => callback(...args);
    ipcRenderer.on(channel, subscription);
    return () => ipcRenderer.removeListener(channel, subscription);
  },

  // Listen once
  once: (channel, callback) => {
    ipcRenderer.once(channel, (_event, ...args) => callback(...args));
  },
});

Secure preload with channel whitelisting

// preload.js — production-grade with explicit channel allowlists
const { contextBridge, ipcRenderer } = require('electron');

const INVOKE_CHANNELS = [
  'db:query',
  'db:insert',
  'file:read',
  'file:write',
  'config:get',
  'config:set',
];

const SEND_CHANNELS = [
  'window:minimize',
  'window:close',
  'app:log',
];

const RECEIVE_CHANNELS = [
  'app:update-available',
  'app:download-progress',
  'navigation:go',
];

contextBridge.exposeInMainWorld('api', {
  invoke: (channel, ...args) => {
    if (!INVOKE_CHANNELS.includes(channel)) {
      throw new Error(`IPC invoke not allowed on channel: ${channel}`);
    }
    return ipcRenderer.invoke(channel, ...args);
  },
  send: (channel, ...args) => {
    if (!SEND_CHANNELS.includes(channel)) {
      throw new Error(`IPC send not allowed on channel: ${channel}`);
    }
    ipcRenderer.send(channel, ...args);
  },
  on: (channel, callback) => {
    if (!RECEIVE_CHANNELS.includes(channel)) {
      throw new Error(`IPC listen not allowed on channel: ${channel}`);
    }
    const subscription = (_event, ...args) => callback(...args);
    ipcRenderer.on(channel, subscription);
    return () => ipcRenderer.removeListener(channel, subscription);
  },
});

Core Patterns

Pattern 1: Request-Response with invoke / handle

This is the most common and recommended pattern. It returns a Promise.

// main.js
const { ipcMain } = require('electron');
const fs = require('node:fs/promises');

ipcMain.handle('file:read', async (_event, filePath) => {
  try {
    const content = await fs.readFile(filePath, 'utf-8');
    return { success: true, data: content };
  } catch (err) {
    return { success: false, error: err.message };
  }
});

ipcMain.handle('db:query', async (_event, sql, params) => {
  // Validate inputs before executing
  if (typeof sql !== 'string') throw new Error('SQL must be a string');
  const results = await database.query(sql, params);
  return results;
});
// renderer.js
async function loadFile(path) {
  const result = await window.api.invoke('file:read', path);
  if (result.success) {
    document.getElementById('editor').value = result.data;
  } else {
    showError(result.error);
  }
}

Pattern 2: One-way fire-and-forget with send / on

Use when the renderer doesn't need a response.

// main.js
const { ipcMain, BrowserWindow } = require('electron');

ipcMain.on('window:minimize', (event) => {
  const win = BrowserWindow.fromWebContents(event.sender);
  win?.minimize();
});

ipcMain.on('window:close', (event) => {
  const win = BrowserWindow.fromWebContents(event.sender);
  win?.close();
});

ipcMain.on('app:log', (_event, level, message) => {
  logger[level]?.(message);
});
// renderer.js
document.getElementById('btn-minimize').addEventListener('click', () => {
  window.api.send('window:minimize');
});

Pattern 3: Main pushes to renderer

// main.js — push updates to a specific window
function notifyRenderer(win, channel, ...args) {
  if (win && !win.isDestroyed()) {
    win.webContents.send(channel, ...args);
  }
}

// Example: push download progress
function startDownload(win, url) {
  downloadManager.on('progress', (percent) => {
    notifyRenderer(win, 'app:download-progress', percent);
  });
  downloadManager.start(url);
}
// renderer.js — listen for pushes from main
const unsubscribe = window.api.on('app:download-progress', (percent) => {
  progressBar.style.width = `${percent}%`;
  progressLabel.textContent = `${percent}%`;
});

// Clean up when done
// unsubscribe();

Pattern 4: Typed IPC with a service layer

// shared/ipc-channels.js — single source of truth
const IPC = {
  FILE_READ: 'file:read',
  FILE_WRITE: 'file:write',
  FILE_PICK: 'file:pick',
  CONFIG_GET: 'config:get',
  CONFIG_SET: 'config:set',
  WINDOW_MINIMIZE: 'window:minimize',
  WINDOW_CLOSE: 'window:close',
};

module.exports = { IPC };
// main.js — register handlers using shared constants
const { IPC } = require('./shared/ipc-channels');

ipcMain.handle(IPC.FILE_READ, handleFileRead);
ipcMain.handle(IPC.FILE_WRITE, handleFileWrite);
ipcMain.handle(IPC.CONFIG_GET, handleConfigGet);
ipcMain.handle(IPC.CONFIG_SET, handleConfigSet);

Pattern 5: Window-to-window communication via main relay

// main.js — relay messages between windows
ipcMain.on('relay', (event, targetWindowId, channel, ...args) => {
  const targetWin = BrowserWindow.fromId(targetWindowId);
  if (targetWin && !targetWin.isDestroyed()) {
    targetWin.webContents.send(channel, ...args);
  }
});

Pattern 6: Bidirectional streaming with MessagePort

// main.js
const { MessageChannelMain } = require('electron');

ipcMain.handle('create-worker-channel', (event) => {
  const { port1, port2 } = new MessageChannelMain();

  // Send port2 to the renderer
  event.sender.postMessage('port', null, [port2]);

  // Use port1 in main process
  port1.on('message', (event) => {
    console.log('Received from renderer:', event.data);
    port1.postMessage({ response: 'acknowledged' });
  });
  port1.start();
});
// preload.js — expose port receiver
const { ipcRenderer } = require('electron');

ipcRenderer.on('port', (event) => {
  const port = event.ports[0];
  contextBridge.exposeInMainWorld('workerPort', {
    send: (data) => port.postMessage(data),
    onMessage: (cb) => { port.onmessage = (e) => cb(e.data); },
  });
});

Best Practices

  • Always use ipcMain.handle / ipcRenderer.invoke for request-response patterns. It returns a Promise and handles errors cleanly.
  • Whitelist IPC channels in the preload script. Never expose a generic ipcRenderer.send without validation.
  • Define channel names as shared constants to avoid typos and enable easy refactoring.
  • Validate all data received in ipcMain handlers. Treat renderer input as untrusted.
  • Always remove IPC listeners when they are no longer needed to prevent memory leaks.
  • Use structured return types ({ success, data, error }) for consistent error handling.
  • Keep IPC handlers thin — delegate to service modules for business logic.

Common Pitfalls

  • Exposing ipcRenderer directly: Never do contextBridge.exposeInMainWorld('ipc', ipcRenderer). This gives the renderer full access to all IPC channels.
  • Forgetting to remove listeners: ipcRenderer.on persists across page navigations in the same window. Always return and call cleanup functions.
  • Using ipcMain.on when handle is better: on doesn't support returning values cleanly. Use handle/invoke for request-response.
  • Sending non-serializable data: IPC uses the structured clone algorithm. Functions, DOM nodes, and circular references cannot be sent.
  • Not checking event.sender: In multi-window apps, verify which window sent the message before acting on it.
  • Large payloads: Sending huge buffers (100+ MB) over IPC can freeze the app. Stream large data through files or MessagePort instead.

Core Philosophy

IPC is the security boundary of your Electron application. The renderer process should be treated as an untrusted client — just as you would treat a browser making requests to your API server. Every message received in the main process must be validated, typed, and handled defensively. The preload script is the controlled gateway that determines exactly what the renderer can and cannot do.

Prefer invoke/handle over send/on for any interaction where the caller needs a response. The invoke pattern returns a Promise, handles errors cleanly, and establishes a clear request-response contract. Fire-and-forget (send/on) is appropriate for notifications and logging, but using it for data operations leads to lost responses and unhandled errors.

Define your IPC channels as a shared contract, not as magic strings scattered across files. A single constants file listing all valid channel names prevents typos, enables IDE autocompletion, and makes it trivial to audit the full API surface exposed to the renderer. If a channel is not in the allowlist, it should not exist.

Anti-Patterns

  • Exposing raw ipcRenderer to the renderer — calling contextBridge.exposeInMainWorld('ipc', ipcRenderer) gives the renderer unrestricted access to every IPC channel, eliminating the security benefit of context isolation.

  • Using magic string channel names — scattering channel names like 'file:read' as bare strings across the codebase leads to typos and makes the IPC API surface impossible to audit; define channels as shared constants.

  • Not validating IPC handler inputs — accepting renderer-supplied file paths, SQL queries, or configuration values without type checking and sanitization exposes the main process to injection attacks.

  • Forgetting to remove listenersipcRenderer.on persists across SPA navigations within the same window; failing to clean up causes memory leaks and duplicate handler execution.

  • Sending non-serializable data — IPC uses the structured clone algorithm; functions, DOM nodes, and circular references cannot be transmitted and will throw or silently fail.

Install this skill directly: skilldb add desktop-app-skills

Get CLI access →