Seit Electron 7 gibt es das Promise-basierte invoke/handle-Pattern — der moderne Default für jede Renderer-zu-Main-Anfrage mit Rückgabewert. RPC-artig, einfach, mit sauberer Error-Propagation. Hier alles, was du wissen musst.
Grundpattern
ipcMain.handle('app:get-version', () => app.getVersion());
ipcMain.handle('files:read', async (_event, path) => {
return await fs.readFile(path, 'utf-8');
});// Preload exposed:
contextBridge.exposeInMainWorld('api', {
getVersion: () => ipcRenderer.invoke('app:get-version'),
readFile: (path) => ipcRenderer.invoke('files:read', path)
});
// Renderer nutzt:
const v = await window.api.getVersion();
const text = await window.api.readFile('/path/to/file');invoke gibt immer ein Promise zurück. handle registriert einen Promise-fähigen Endpoint — der Handler kann sync oder async sein, Postgres-Promise wird automatisch awaited.
Async-Handler
ipcMain.handle('users:get-active', async (_event) => {
const db = await openDatabase();
const rows = await db.query('SELECT * FROM users WHERE active = true');
return rows;
});Im Renderer normal awaiten:
const users = await window.api.users.getActive();Während der Handler läuft, blockiert der Renderer nicht — er kann andere Operationen parallel ausführen. Der Main-Prozess blockiert auch nicht (außer durch sync Operationen im Handler selbst).
Error-Handling
ipcMain.handle('files:read', async (_event, path) => {
// Validation
if (!isAllowedPath(path)) {
throw new Error('Path not allowed');
}
try {
return await fs.readFile(path, 'utf-8');
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error('Datei nicht gefunden');
}
throw err;
}
});try {
const text = await window.api.readFile(path);
} catch (err) {
// err.message kommt vom Main-throw
showToast('Fehler: ' + err.message);
}Fehler im Handler werden zur Promise-Rejection im Renderer. Der Stack-Trace ist allerdings nicht sehr nützlich — er endet an der IPC-Bridge. Fehler-Messages sollten daher selbst-erklärend sein.
Argumente und Rückgaben
Beides wird Structured-Clone'd:
ipcMain.handle('user:save', (_event, user) => {
// user = { id: 42, name: 'Anna', tags: ['admin'], created: new Date() }
db.save(user);
return { ok: true, id: user.id };
});
// Renderer
const result = await window.api.user.save({
id: 42,
name: 'Anna',
tags: ['admin'],
created: new Date()
});// Funktionen — werden bei der Übertragung verloren
const callback = () => {};
await window.api.foo({ cb: callback }); // cb ist im Main undefined
// DOM-Knoten
await window.api.foo(document.body); // throws
// Klassen-Instanzen mit Methoden
class User { greet() { return 'hi'; } }
await window.api.foo(new User()); // greet() ist verlorenWorkaround: Funktionen über separate Subscriptions, DOM-Knoten via outerHTML-String, Klassen via Plain-Object und Rekonstruktion auf der anderen Seite.
Performance
Jeder invoke-Call hat einen Roundtrip-Aufwand: Argumente serialisieren, IPC-Channel, Main-Auswertung, Rück-Serialisierung. Größenordnung: einige hundert Mikrosekunden.
Pragmatische Folgen:
- Bulk-Operationen lieber als ein Call mit Array statt N einzelne Calls
- Hot-Path-Loops nicht via IPC machen — wenn 10000× pro Sekunde gerufen wird, lieber Daten im Renderer cachen
- Streaming nicht über invoke (das ist Request-Response) — dafür
MessageChannelnutzen
for (const id of ids) {
const user = await window.api.users.getById(id); // 1000× IPC
}const users = await window.api.users.getByIds(ids); // 1× IPCDoppelte Registrierung
Im Gegensatz zu ipcMain.on darf ipcMain.handle nicht mehrfach für denselben Channel registriert werden — sonst Fehler.
// FALSCH: doppelt
ipcMain.handle('foo', handlerA);
ipcMain.handle('foo', handlerB);
// Error: Attempted to register a second handler for 'foo'
// RICHTIG: vorher entfernen
ipcMain.removeHandler('foo');
ipcMain.handle('foo', newHandler);
// ODER: Hot-Reload-fähiges Pattern in Dev-Mode
if (ipcMain.eventNames().includes('foo')) {
ipcMain.removeHandler('foo');
}
ipcMain.handle('foo', handler);Bei Hot-Reload (Vite/Webpack) kann das wichtig werden — beim erneuten Laden des Main-Codes würde sonst die zweite Registrierung fehlschlagen.
Besonderheiten
invoke/handle ist Default für RPC-artige Calls.
Renderer fragt Main, will Antwort. Sauberes Pattern, async/await funktioniert wie erwartet, Fehler-Propagation automatisch. Für 95 % aller Renderer-zu-Main-Cases die richtige Wahl.
Stack-Trace bei Fehlern endet an der IPC-Bridge.
Im Renderer siehst du den Fehler, aber der Stack zeigt nur bis zum invoke-Aufruf. Wer mehr Diagnose will: Fehler im Main loggen mit Original-Stack, dann eine kompakte Message zum Renderer werfen.
Hot-Reload braucht removeHandler.
Bei Dev mit Reload des Main-Codes wirft die zweite handle-Registrierung. Pattern: try { ipcMain.removeHandler('channel'); } catch {} vor jedem handle. Oder beim App-Start einmal alle entfernen.
invoke ist async — Hauptthread bleibt frei.
Während die Antwort kommt, kann der Renderer rendern, andere IPCs feuern, User-Interaktion verarbeiten. Im Gegensatz zu sendSync (das blockt den Renderer).
Bulk-Argumente sind günstiger als viele Einzel-Calls.
Pro IPC-Call hast du Roundtrip-Cost. Bei 1000 Aufrufen merkbar. Lieber ein Call mit Array. Klassisches Anti-Pattern: in einer Schleife ein invoke.
Streaming geht NICHT über invoke.
invoke ist Request-Response. Wer Echtzeit-Daten braucht (Log-Stream, Download-Progress): kombinieren mit webContents.send für Push-Events oder MessageChannel für Bidirektional.