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

JavaScript main.js
ipcMain.handle('app:get-version', () => app.getVersion());

ipcMain.handle('files:read', async (_event, path) => {
    return await fs.readFile(path, 'utf-8');
});
JavaScript preload.js / renderer.js
// 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

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

JavaScript
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

JavaScript main.js
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;
    }
});
JavaScript renderer.js
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:

JavaScript Funktioniert
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()
});
JavaScript Funktioniert NICHT
// 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 verloren

Workaround: 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 MessageChannel nutzen
JavaScript Schlecht: 1000 einzelne Calls
for (const id of ids) {
    const user = await window.api.users.getById(id);  // 1000× IPC
}
JavaScript Gut: ein Bulk-Call
const users = await window.api.users.getByIds(ids);   // 1× IPC

Doppelte Registrierung

Im Gegensatz zu ipcMain.on darf ipcMain.handle nicht mehrfach für denselben Channel registriert werden — sonst Fehler.

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

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu IPC

Zur Übersicht