Inter-process communication, or IPC, is one of the most important pieces in a feature-rich Electron desktop application.
In day-to-day Electron development, cross-process communication shows up constantly. The most common cases are:
Before getting into specific patterns, it is worth understanding Electron's context isolation model:
https://www.electronjs.org/docs/latest/tutorial/context-isolation
The reason this matters is that preload scripts are the foundation for safe communication. Once you understand context isolation and the Electron process model, the IPC design starts to feel much more natural.
By default, the renderer process cannot directly access Node.js or Electron modules. The contextBridge API lets you expose a controlled subset of capabilities from preload into the renderer.
Create src/preload/index.ts:
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("nativeBridge", {
openUrlByDefaultBrowser: (url: string) =>
ipcRenderer.send("openUrlByDefaultBrowser", url),
});Then add the preload entry in src/main/index.ts:
preload: join(__dirname, "../preload/index.js");At this point, the project structure should include the preload layer.
If you start the dev environment now, you will likely see an error like this:
That happens because Electron reads the built output rather than your raw source files. So the preload directory also needs its own Vite build step.
config/vite/preload.js:
import { builtinModules } from "module";
import path from "path";
const config = {
root: process.cwd(),
resolve: {
alias: {
"@": path.resolve(__dirname, "../../src"),
},
},
build: {
outDir: path.resolve(__dirname, "../../dist/preload"),
minify: false,
target: `node16`,
lib: {
entry: path.resolve(__dirname, "../../src/preload/index.ts"),
formats: ["cjs"],
},
rollupOptions: {
external: ["electron", ...builtinModules],
output: {
entryFileNames: "[name].cjs",
},
},
emptyOutDir: true,
brotliSize: false,
chunkSizeWarningLimit: 2048,
},
};
export default config;scripts/dev.js:
const preloadDev = {
async createRenderServer(viteDevServer) {
const options = {
...sharedOptions,
configFile: path.resolve(__dirname, "../config/vite/preload.js"),
};
return build({
...options,
plugins: [
{
name: "reload-page-on-preload-package-change",
writeBundle() {
viteDevServer.ws.send({
type: "full-reload",
});
},
},
],
});
},
};
const initDev = async () => {
try {
const renderDevServer = await renderDev.createRenderServer();
await preloadDev.createRenderServer(renderDevServer);
await mainDev.createMainServer(renderDevServer);
} catch (err) {
console.error(err);
}
};
initDev();Because the preload build now outputs CommonJS, the preload path in src/main/index.ts also needs to point to the .cjs file:
preload: join(__dirname, "../preload/index.cjs");At that point, the preload layer is ready.
There are several variants here. The most common split is one-way communication and request-response communication.
A simple example is clicking a button in the renderer and opening the default system browser.
In the renderer:
const openUrlByDefaultBrowser = () => {
window.nativeBridge.openUrlByDefaultBrowser("https://www.baidu.com");
};
<button onClick={openUrlByDefaultBrowser}>openUrlByDefaultBrowser</button>;In the main process:
const openUrlByDefaultBrowser = (e: IpcMainEvent, args: any) => {
shell.openExternal(args);
};
app.whenReady().then(() => {
createWindow();
ipcMain.on("openUrlByDefaultBrowser", openUrlByDefaultBrowser);
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});The flow is:
Add a send method in preload and listen for a reply event:
import { contextBridge, ipcRenderer } from "electron";
contextBridge.exposeInMainWorld("nativeBridge", {
openUrlByDefaultBrowser: (url: string) =>
ipcRenderer.send("openUrlByDefaultBrowser", url),
communicateWithEachOtherSendMsg: (msg: string) =>
ipcRenderer.send("communicateWithEachOtherSend", msg),
});
ipcRenderer.on("communicateWithEachOtherReply", (_event, arg) => {
console.log(arg);
});In the main process:
ipcMain.on("communicateWithEachOtherSend", (event, arg) => {
event.reply("communicateWithEachOtherReply", `I got ${arg}, ok`);
});In the renderer:
const communicateWithEachOtherSendMsg = () => {
window.nativeBridge.communicateWithEachOtherSendMsg("Hello");
};
<button onClick={communicateWithEachOtherSendMsg}>
communicateWithEachOtherSendMsg
</button>;If you want to be more precise, this pattern can be split into three flavors:
That is the example above.
// preload
communicateWithEachOtherSendMsgSendSync: (msg: string) =>
ipcRenderer.sendSync("communicateWithEachOtherSendSync", msg)// main
ipcMain.on("communicateWithEachOtherSendSync", (event, arg) => {
event.returnValue = `I got ${arg}, ok`;
});// renderer
const communicateWithEachOtherSendMsgSendSync = () => {
const msg =
window.nativeBridge.communicateWithEachOtherSendMsgSendSync("Hello sync");
console.log(msg);
};// preload
communicateWithEachOtherSendMsgPromise: (msg: string) =>
ipcRenderer.invoke("communicateWithEachOtherSendPromise", msg)// main
ipcMain.handle("communicateWithEachOtherSendPromise", async (event, arg) => {
return `I got ${arg}, ok`;
});// renderer
const communicateWithEachOtherSendMsgPromise = () => {
window.nativeBridge
.communicateWithEachOtherSendMsgPromise("Hello Promise")
.then((msg: any) => {
console.log(msg);
});
};As a small example, clicking a menu item in the main process updates state in the renderer.
Main process:
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
click: () => {
mainWindow.webContents.send("update-counter", 1);
},
label: "IncrementNumber",
},
],
},
]);
Menu.setApplicationMenu(menu);Add a listener in preload:
onUpdateCounterByMain: (callback: any) => {
ipcRenderer.on("update-counter", (e, value) => {
callback(e, value);
});
}Use it in the renderer:
useEffect(() => {
window.nativeBridge.onUpdateCounterByMain((e: Event, value: any) => {
setCount((pre) => {
return pre + value;
});
});
}, []);Note: under React 18, if Strict Mode stays enabled, this effect may appear to run twice in development.
Electron does not provide a direct ipcRenderer to ipcRenderer path. In practice, there are two common approaches:
MessagePortBefore demonstrating those, we first need a hidden background renderer. In this article, it is called the work process. Its build setup is very similar to the main renderer process.
Key steps:
config/vite/work.jsscripts/dev.jsconfig/vite/work.js:
import { builtinModules } from "module";
import path from "path";
const config = {
root: path.resolve(__dirname, "../../src/work"),
envDir: process.cwd(),
resolve: {
alias: {
"@": path.resolve(__dirname, "../../src"),
},
},
build: {
outDir: path.resolve(__dirname, "../../dist/work"),
assetsDir: ".",
minify: false,
target: `node16`,
lib: {
entry: path.resolve(__dirname, "../../src/work/index.ts"),
formats: ["cjs"],
},
rollupOptions: {
external: ["electron", ...builtinModules],
output: {
entryFileNames: "[name].cjs",
},
},
emptyOutDir: true,
brotliSize: false,
chunkSizeWarningLimit: 2048,
},
};
export default config;scripts/dev.js:
const workDev = {
async createRenderServer(viteDevServer) {
const options = {
...sharedOptions,
configFile: path.resolve(__dirname, "../config/vite/work.js"),
};
return build({
...options,
plugins: [
{
name: "reload-page-on-work-package-change",
writeBundle() {
viteDevServer.ws.send({
type: "full-reload",
});
},
},
],
});
},
};
const initDev = async () => {
try {
const renderDevServer = await renderDev.createRenderServer();
await preloadDev.createRenderServer(renderDevServer);
await workDev.createRenderServer(renderDevServer);
await mainDev.createMainServer(renderDevServer);
} catch (err) {
console.error(err);
}
};Work process:
Main process:
const workWindow = new BrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
preload: join(__dirname, "../work/index.cjs"),
},
});
workWindow.hide();
if (import.meta.env.MODE === "dev") {
workWindow.webContents.openDevTools();
}
workWindow.loadFile(resolve(__dirname, "../work/index.html"));If everything is wired correctly, pnpm run dev should bring up the new process:
Here the goal is for the renderer process to send a message to the work process.
// src/preload/index.ts
renderSendMsgToWork: (msg: any) => {
ipcRenderer.send("renderSendMsgToWork", msg);
}// src/render/App.tsx
const sendMsgToWork = () => {
window.nativeBridge.renderSendMsgToWork("I am render");
};
<div className="div-shape">
<button onClick={sendMsgToWork}>sendMsgToWork</button>
</div>;// src/main/index.ts
ipcMain.on("renderSendMsgToWork", (event: Event, msg: any) => {
workWindow && workWindow.webContents.send("msgFormRender", msg);
});// src/work/index.ts
ipcRenderer.on("msgFormRender", (event: Event, msg: any) => {
console.log("msgFormRender:", msg);
});After pnpm run dev, click the button in the renderer and inspect the work process console:
If needed, you can also build the reply path in the opposite direction the same way.
MessagePort About MessagePort:
https://developer.mozilla.org/en-US/docs/Web/API/MessagePort
Inside a renderer process, MessagePort behaves the same way it does on the web. But the main process is not a webpage, so Electron adds:
The overall idea is:
MessageChannelMain in the main processMain process:
const { port1, port2 } = new MessageChannelMain();
mainWindow.once("ready-to-show", () => {
mainWindow.webContents.postMessage("port", null, [port1]);
});
workWindow.once("ready-to-show", () => {
workWindow.webContents.postMessage("port", null, [port2]);
});Renderer preload:
renderSendMsgToWorkByMessagePort: (msg: any) => {
window.electronMessagePort && window.electronMessagePort.postMessage(msg);
}
ipcRenderer.on("port", (e) => {
window.electronMessagePort = e.ports[0];
});Work process:
ipcRenderer.on("port", (e) => {
const electronMessagePort = e.ports[0];
electronMessagePort.onmessage = (msg: any) => {
console.log("window.electronMessagePort.onmessage work:", msg.data);
};
});Then send a message from the renderer:
const sendMsgToWorkByMessagePort = () => {
window.nativeBridge.renderSendMsgToWorkByMessagePort(
"I am render, sendMsgToWorkByMessagePort"
);
};
<div className="div-shape">
<button onClick={sendMsgToWorkByMessagePort}>
sendMsgToWorkByMessagePort
</button>
</div>;All of the communication patterns above show up regularly in Electron development. Which one you should use depends on the business scenario, but once you have these patterns clearly encapsulated, daily development becomes much faster and more predictable.