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-ProzessRenderer-Prozess
AnzahlGenau 11 oder mehrere (pro BrowserWindow)
LaufzeitNode.jsChromium
Hat Zugriff auffs, child_process, native APIsDOM, Web-APIs (Canvas, WebGL, …)
Verantwortlich fürFenster, Menüs, System-ZugriffeUI rendern
Sandbox-DefaultNeinJa (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:

VarianteAPIUse-Case
invoke / handleipcRenderer.invoke() + ipcMain.handle()Renderer fragt Main, erwartet Antwort (Promise)
send / onipcRenderer.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

JavaScript main.js
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

JavaScript preload.js
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

JavaScript renderer.js
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:

JavaScript main.js – Event auslösen
// beliebiger Trigger im Main
win.webContents.send('download:progress', { percent: 42 });
JavaScript preload.js – Listener exponieren
contextBridge.exposeInMainWorld('api', {
    onDownloadProgress: (callback) => {
        const listener = (_event, data) => callback(data);
        ipcRenderer.on('download:progress', listener);
        return () => ipcRenderer.removeListener('download:progress', listener);
    }
});
JavaScript renderer.js – Listener registrieren
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 / handlesend / on
RichtungRenderer → Main → AntwortBeide Richtungen, kein Return-Value
RückgabePromise mit ResultatKein Return — Antwort via separatem Channel
FehlerPromise rejectedKein automatisches Error-Handling
Use-CaseDaten holen, Aktionen mit ResultatPush-Events, Notifications
Empfohlen seitElectron 7Klassisch — 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:

JavaScript
// 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

/ Weiter

Zurück zu IPC

Zur Übersicht