Electron-Apps sind in zwei Welten geteilt: einen Node.js-Main-Prozess für System-Zugriffe und einen oder mehrere Chromium-Renderer für die UI. Beide laufen isoliert — und kommunizieren ausschließlich über IPC (Inter-Process Communication). Hier die Grundlagen, das sichere Pattern mit contextBridge, und die Stolperfallen.
Die zwei Prozess-Welten
Eine Electron-App besteht immer aus mindestens zwei Prozessen:
| Main-Prozess | Renderer-Prozess | |
|---|---|---|
| Anzahl | Genau 1 | 1 oder mehrere (pro BrowserWindow) |
| Laufzeit | Node.js | Chromium |
| Hat Zugriff auf | fs, child_process, native APIs | DOM, Web-APIs (Canvas, WebGL, …) |
| Verantwortlich für | Fenster, Menüs, System-Zugriffe | UI rendern |
| Sandbox-Default | Nein | Ja (mit sandbox: true) |
Renderer haben bewusst keinen direkten Zugriff auf Node-APIs — das ist der Sicherheitskern von Electron. Wenn der Renderer etwas im Dateisystem will, muss er den Main fragen. Das passiert via IPC.
Zwei IPC-Patterns
Electron bietet zwei Varianten:
| Variante | API | Use-Case |
|---|---|---|
| invoke / handle | ipcRenderer.invoke() + ipcMain.handle() | Renderer fragt Main, erwartet Antwort (Promise) |
| send / on | ipcRenderer.send() + ipcMain.on() | Fire-and-forget, oder Main → Renderer Events |
invoke/handle ist der moderne Default — Promise-basiert, eleganter, klare Request-Response-Semantik. send/on ist die ältere Variante, sinnvoll für Push-Nachrichten vom Main an den Renderer (Notifications, Status-Updates).
Das sichere Pattern — contextBridge
Der Renderer darf ipcRenderer nicht direkt benutzen. Dafür gibt es das Preload-Skript: ein privilegiertes Stück Code, das Zugriff auf beide Welten hat und über contextBridge eine kontrollierte API ans window-Objekt hängt.
main.js — Fenster mit Preload starten
import { app, BrowserWindow, ipcMain } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false
}
});
win.loadFile('index.html');
}
// Handler für vom Renderer aufgerufene Methoden
ipcMain.handle('app:get-version', () => app.getVersion());
ipcMain.handle('dialog:greet', (_event, name) => `Hallo, ${name}!`);
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});ipcMain.handle('kanal', handler) registriert einen Promise-fähigen Endpunkt. Der Handler bekommt das Event-Objekt und alle Argumente, die der Renderer mitschickt.
preload.js — die kontrollierte Brücke
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
getVersion: () => ipcRenderer.invoke('app:get-version'),
greet: (name) => ipcRenderer.invoke('dialog:greet', name)
});exposeInMainWorld('api', {...}) legt das Objekt unter window.api im Renderer ab. Wichtig: NUR die Methoden weiterreichen, die der Renderer wirklich braucht — nicht das ganze ipcRenderer-Objekt. Sonst hebelst du die Sandbox aus.
renderer.js — sauberer Aufruf aus dem Renderer
const version = await window.api.getVersion();
console.log('App-Version:', version);
const message = await window.api.greet('Anna');
document.getElementById('greeting').textContent = message;Im Renderer existiert ipcRenderer nicht — nur die explizit über contextBridge exponierte API.
Main → Renderer — Events vom Main
Für Push-Nachrichten (Status-Updates, Progress, Notifications) geht es andersherum:
// beliebiger Trigger im Main
win.webContents.send('download:progress', { percent: 42 });contextBridge.exposeInMainWorld('api', {
onDownloadProgress: (callback) => {
const listener = (_event, data) => callback(data);
ipcRenderer.on('download:progress', listener);
return () => ipcRenderer.removeListener('download:progress', listener);
}
});const unsubscribe = window.api.onDownloadProgress(({ percent }) => {
updateProgressBar(percent);
});
// beim Cleanup
unsubscribe();Das Cleanup-Pattern (Unsubscribe-Funktion zurückgeben) ist Pflicht — sonst sammelst du Listener bei jedem Re-Mount der UI an.
invoke vs. send im Vergleich
invoke / handle | send / on | |
|---|---|---|
| Richtung | Renderer → Main → Antwort | Beide Richtungen, kein Return-Value |
| Rückgabe | Promise mit Resultat | Kein Return — Antwort via separatem Channel |
| Fehler | Promise rejected | Kein automatisches Error-Handling |
| Use-Case | Daten holen, Aktionen mit Resultat | Push-Events, Notifications |
| Empfohlen seit | Electron 7 | Klassisch — weiter unterstützt |
Faustregel: für Renderer fragt Main immer invoke/handle. Für Main pushed an Renderer webContents.send + ipcRenderer.on.
Channel-Naming und API-Design
Channels sind nur Strings — Typen werden nicht erzwungen. Konventionen helfen:
// gut: namespace:action
ipcMain.handle('files:read', ...)
ipcMain.handle('files:write', ...)
ipcMain.handle('window:minimize', ...)
// weniger gut: flach
ipcMain.handle('readFile', ...)
ipcMain.handle('minimize', ...)In TypeScript-Projekten lohnt sich eine zentrale Channel-Map mit Typen — dann sind Tippfehler beim Channel-Namen Compile-Errors.
Häufige Stolperfallen
Niemals ipcRenderer direkt im Renderer verwenden.
nodeIntegration: true oder contextIsolation: false würde den direkten Zugriff erlauben — und damit die ganze Sandbox-Logik aushebeln. Das ist seit Electron 12 nicht mehr Default und sollte es nie wieder sein. Immer über contextBridge.
Nicht das ganze ipcRenderer exposen.
contextBridge.exposeInMainWorld('ipc', ipcRenderer) ist ein klassischer Fehler. Damit kann der Renderer auf jeden beliebigen Channel pushen — auch interne. Stattdessen: für jede Funktion einen eigenen Wrapper, der nur auf den vorgesehenen Channel ruft.
Argumente vom Renderer im Main IMMER validieren.
Renderer-Inputs sind wie HTTP-Requests an einen Server — potenziell bösartig (z. B. wenn der Renderer eine kompromittierte Webseite lädt). Im Main-Handler immer Type-Check, Pfad-Validierung, Whitelist auf erlaubte Werte. Niemals direkt an fs.unlink oder child_process.exec weiterreichen.
removeListener/off nicht vergessen.
Ein ipcRenderer.on('event', cb) ohne späteres removeListener führt zu Listener-Leaks — bei jedem Component-Mount kommt einer dazu, alte werden nicht entfernt. Das Unsubscribe-Pattern (Cleanup-Funktion zurückgeben) löst das.
Synchrones sendSync blockiert den Renderer.
ipcRenderer.sendSync existiert noch, blockiert aber den Renderer-Prozess komplett, bis der Main antwortet. Bei langsamer Operation friert die UI ein. Außer für absolut triviale Lookups beim App-Start: nicht verwenden, immer invoke.
ESM vs. CommonJS — Preload-Skripte sind seit v28 ESM-fähig.
Vor Electron 28 mussten Preload-Skripte CommonJS sein (require). Seit v28 mit "type": "module" in package.json können sie ESM nutzen (import). Wer in alten Beispielen require('electron') sieht: das geht heute oft nicht mehr — import { contextBridge } from 'electron' ist der Weg.
Weiterführende Ressourcen
Externe Quellen
- Inter-Process Communication — Offizielles Tutorial mit allen Patterns
- ipcMain — API-Referenz
- ipcRenderer — API-Referenz
- contextBridge — API-Referenz und Best Practices
- Process Sandboxing — Hintergrund zum Sandbox-Modell
- Security Checklist — IPC-relevante Sicherheits-Punkte