Die IPC-Kommunikation in Electron läuft über zwei Module: ipcMain im Main-Prozess als Server-Seite und ipcRenderer im Renderer als Client-Seite. Hier die APIs beider Module im Vergleich, mit den zwei Patterns send/on und invoke/handle.
Die zwei Module
| Modul | Wo | Aufgabe |
|---|---|---|
ipcMain | Main-Prozess | Empfängt Nachrichten von Renderern, kann an Renderer pushen |
ipcRenderer | Renderer / Preload | Sendet Nachrichten an Main, empfängt vom Main |
Sie sprechen über benannte Channels (frei wählbare Strings), und zwar in zwei Varianten: das klassische send/on (Fire-and-Forget mit Events) und das modernere invoke/handle (Promise-basiert mit Rückgabe).
ipcMain — die Server-Seite
import { app, BrowserWindow, ipcMain } from 'electron';
// Pattern 1: invoke/handle — modern, Promise-basiert
ipcMain.handle('app:get-version', () => app.getVersion());
ipcMain.handle('files:read', async (_event, path) => {
return fs.readFile(path, 'utf-8');
});
// Pattern 2: send/on — klassisch, Fire-and-Forget
ipcMain.on('analytics:event', (_event, eventName, payload) => {
sendToAnalytics(eventName, payload);
});ipcMain.handle(channel, handler) registriert einen Promise-fähigen Endpunkt. ipcMain.on(channel, handler) einen klassischen Event-Listener ohne Rückgabewert.
Pro Channel und Variante darfst du nur einen Handler haben — handle darf nicht doppelt registriert sein.
ipcRenderer — die Client-Seite
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
// Pattern 1: invoke — Promise zurück
getVersion: () => ipcRenderer.invoke('app:get-version'),
readFile: (path) => ipcRenderer.invoke('files:read', path),
// Pattern 2: send — kein Rückgabewert
track: (eventName, payload) => ipcRenderer.send('analytics:event', eventName, payload)
});Im Renderer-Code dann:
const v = await window.api.getVersion();
const text = await window.api.readFile('/path/to/file');
window.api.track('button_click', { id: 'submit' });Das Event-Objekt
Der Handler im Main bekommt als erstes Argument ein event-Objekt — mit Metadaten zum Sender:
ipcMain.handle('files:read', async (event, path) => {
console.log('Anfrage von Frame-ID:', event.frameId);
console.log('Anfrage von WebContents:', event.sender.id);
// Antwort gezielt an dieses Window pushen
event.sender.send('progress:update', { percent: 50 });
return fs.readFile(path, 'utf-8');
});event.sender ist die webContents-Instanz des sendenden Renderers — wichtig für „Antwort nur an diesen einen Renderer schicken", besonders bei Multi-Window-Apps.
Vergleich der zwei Patterns
invoke / handle | send / on | |
|---|---|---|
| Richtung | Renderer → Main mit Antwort | Beide Richtungen, ohne Antwort |
| Return-Value | Ja, als Promise | Nein |
| Error-Handling | Promise rejected automatisch | Manuell |
| Use-Case | Daten holen, RPC-artige Calls | Fire-and-Forget, Push-Events |
| Empfohlen seit | Electron 7 | Klassisch |
Faustregel: invoke/handle für jeden Renderer-zu-Main-Call mit Rückgabe. send/on nur für reine Push-Events vom Main an den Renderer (webContents.send + ipcRenderer.on) oder Fire-and-Forget-Logging.
Channel-Naming
Channels sind nur Strings — Typisierung gibt es nicht eingebaut. Konventionen helfen:
// gut: namespace:action
'app:get-version'
'files:read'
'files:write'
'theme:set'
'window:minimize'
// weniger gut: flach
'getVersion'
'readFile'
'minimize'Mit Namespaces siehst du auf einen Blick, welcher Bereich der App betroffen ist. Bei TypeScript-Setups lohnt sich eine zentrale Channel-Map mit Typen.
Interessantes
ipcRenderer NIE direkt im Renderer.
Mit contextIsolation: true ist es da gar nicht zugänglich. Mit contextIsolation: false würde es funktionieren — aber auch von kompromittiertem Web-Code aus. Immer über Preload + contextBridge.
Pro Channel ein Handler — keine Mehrfach-Registrierung bei handle.
ipcMain.handle('foo', ...) zweimal aufzurufen wirft einen Fehler. ipcMain.on('foo', ...) darf hingegen mehrere Listener haben — Event-basiert, alle werden gerufen.
event.sender für gezielte Antworten.
Bei Multi-Window-Apps unterscheiden, woher der Call kam. event.sender.send(...) antwortet nur dem ursprünglichen Renderer. BrowserWindow.getAllWindows().forEach(w => w.webContents.send(...)) würde an alle broadcasten.
Fehler in Handlern werden zur Promise-Rejection.
Wenn ein handle-Handler eine Exception wirft, lehnt der Renderer-invoke mit derselben Fehlermeldung ab. Damit funktioniert try/catch im Renderer wie erwartet — sehr saubere Error-Propagation.
Argumente werden Structured-Clone'd.
Was du mitgibst, muss serialisierbar sein. Plain-Objects, Arrays, Strings, Numbers — alles okay. Funktionen, Klassen-Instanzen mit Methoden, DOM-Knoten — nein. Buffer und Date funktionieren.
Channel-Map als Single Source of Truth.
Bei wachsenden Apps lohnt eine zentrale Datei mit allen Channel-Konstanten plus TypeScript-Typen für Argumente und Returns. Verhindert Tippfehler und macht Refactorings sicher.