Preload and IPC
Handle two-way IPC
Pair ipcRenderer.invoke with ipcMain.handle when the renderer needs a result from the main process.
Loading notes is a request that expects one answer. The renderer asks, the main process responds. For this shape Electron gives us a pair: ipcRenderer.invoke() on one side, ipcMain.handle() on the other.
Register the handler in the main process
Each handler is registered once, at startup, in src/index.js:
const { app, BrowserWindow, ipcMain } = require('electron')
ipcMain.handle('notes:load', event => {
assertTrustedSender(event.senderFrame)
return []
})
app.whenReady().then(() => {
createWindow()
})
For now the handler returns an empty array. We’ll connect it to the disk in the next module. assertTrustedSender is a small function we write in the next lesson. It checks that the request comes from our window and not from some other frame.
Call it from the renderer
In src/renderer.js, ask for the notes and draw them:
async function start() {
notes = await window.notesAPI.loadNotes()
renderNotes()
}
start()
invoke() returns a promise. Whatever the main handler returns becomes the resolved value in the renderer. Here that’s the empty array, so the list renders with no items.
Validate before you answer
The main process must check the sender before it returns data or does privileged work. That’s what the first line of the handler does. Validating the data itself, the note shapes and sizes, is the next lesson.
One channel, one request shape
It’s tempting to write one generic handler, something like notes:command, that takes an operation name and a payload. Don’t. Separate handlers make validation, authorization, and error behavior visible. When you read ipcMain.handle('notes:save', ...) you know exactly what arrives and what should be checked.
Prefer this over synchronous IPC
Electron also has ipcRenderer.sendSync(). My advice is to never use it. It blocks the renderer until the main process answers, so the interface freezes. And if the main process is waiting on the renderer for something else, both sides freeze forever. The invoke and handle pair is asynchronous and avoids all of that.
Errors cross the boundary, but not intact
If the handler throws, the renderer’s promise rejects. That’s useful. But Electron does not preserve arbitrary error details across IPC. Custom error properties disappear, and you don’t want file paths and stack traces reaching the page anyway.
Return user-safe messages to the renderer, like “Could not load notes”. Keep the detailed diagnostics in the main-process log, where you can read them.
To confirm the wiring, add a console.log inside the handler, then call window.notesAPI.loadNotes() twice from the DevTools console. You should see exactly two log lines in the terminal, both from the same single handler.
A common mistake is to put ipcMain.handle inside createWindow(). It works until a second window is created. On macOS, close the window, click the Dock icon, and the app throws because the channel already has a handler. Keep handlers at the top level of src/index.js, out of the window function.
Lesson completed