What this usually means
The most common cause is that the preload script is either not loaded, loaded incorrectly, or fails to expose the IPC bridge via contextBridge. Since Electron 12, contextIsolation defaults to true, meaning the renderer cannot access ipcRenderer directly—you must use a preload script that calls contextBridge.exposeInMainWorld. If that script throws (e.g., due to a missing module, a syntax error, or a CSP violation), the bridge is never created, and all IPC calls vanish. Another frequent cause: using ipcRenderer.on in the renderer without removing the listener, causing memory leaks or duplicate handlers. Also check that webPreferences.nodeIntegration is false (the secure default) and that you're actually using the contextBridge API.
The first ten minutes — establish facts before touching code.
- 1Open DevTools in the renderer (Ctrl+Shift+I) and type window.electronAPI — if undefined, the preload failed or contextBridge didn't run.
- 2Check the renderer console for any red errors—especially preload script parse errors or TypeErrors.
- 3In main process, add a console.log inside ipcMain.handle('my-channel', ...) — if no log, the message never arrived.
- 4Verify preload script path in BrowserWindow constructor: it must be absolute, using path.join(__dirname, 'preload.js').
- 5Run with --enable-logging=file and check the log file for preload errors or security warnings.
- 6Temporarily set contextIsolation: false and nodeIntegration: true — if IPC works, your contextBridge setup is broken.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchmain.js (or main process entry): BrowserWindow webPreferences config
- searchpreload.js: contextBridge.exposeInMainWorld calls and ipcRenderer usage
- searchRenderer console (F12): any red errors, plus window.electronAPI value
- searchDevTools Sources tab: verify preload.js is listed and breakpoints work
- searchElectron's renderer process log: ~/Library/Logs/electron (macOS) or %APPDATA%\electron (Windows)
- searchpackage.json: main field points to correct main process file
- searchAny CSP headers or meta tags that might block inline scripts in production
Practical causes, not theory. These are the things you will actually find.
- warningcontextIsolation: true (default) but no preload script or missing contextBridge.exposeInMainWorld
- warningPreload script path is relative instead of absolute—works in dev but fails when packaged
- warningSyntax error or runtime exception in preload.js stops script execution silently
- warningUsing ipcRenderer.send in renderer without contextBridge, expecting it to be global
- warningipcMain.handle never called because channel name mismatched (typo) between main and renderer
- warningRenderer process crashes before IPC call due to unhandled exception
- warningCSP (Content Security Policy) blocks inline scripts or eval, but preload is loaded correctly
Concrete fix directions. Pick the one that matches your root cause.
- buildEnsure preload script path uses path.join(__dirname, 'preload.js') and is absolute.
- buildWrap preload code in try/catch and log errors to stderr so they appear in DevTools.
- buildUse contextBridge.exposeInMainWorld('electronAPI', { send: (channel, data) => ipcRenderer.send(channel, data) }) and call window.electronAPI.send('channel', data) in renderer.
- buildVerify channel names match exactly—no hidden spaces, case differences, or extra slashes.
- buildAdd error handling to ipcMain.handle: always return a value or throw a structured error that the renderer can catch.
- buildFor production builds, test the packaged app early—npx electron-builder and run the .app/.exe.
A fix you cannot prove is a guess. Close the loop.
- verifiedOpen DevTools renderer console, type window.electronAPI.send('ping', 'test') and check main process logs for receipt.
- verifiedIn main process, after webContents.send, add a setTimeout to log, then verify renderer receives via ipcRenderer.on.
- verifiedSet a breakpoint in preload.js at contextBridge.exposeInMainWorld line and step through.
- verifiedAdd a global error handler: process.on('uncaughtException') in main and window.onerror in renderer.
- verifiedRun Electron with --inspect=5858 and attach Chrome DevTools to the main process to inspect IPC events.
- verifiedUse ipcMain.on('*') as a catch-all in dev to log all incoming IPC messages (temporary).
Things that make this bug worse or harder to find.
- warningDo NOT set nodeIntegration: true and contextIsolation: false in production—massive security hole.
- warningDo NOT call ipcRenderer directly from renderer context—it's undefined when contextIsolation is true.
- warningDo NOT forget to call contextBridge.exposeInMainWorld in preload—without it, nothing is exposed.
- warningDo NOT use relative paths in BrowserWindow preload—always use __dirname + path.join.
- warningDo NOT assume the renderer loads before main sends messages—use 'did-finish-load' event.
- warningDo NOT ignore DevTools console errors—a tiny typo in preload silences the whole bridge.
The Silent Preload Crash
Timeline
- 09:15Deploy team reports new build crashes silently—no errors in production.
- 09:22Check renderer console: 'Uncaught TypeError: Cannot read property 'send' of undefined'.
- 09:30Verify window.electronAPI is undefined in DevTools.
- 09:35Review preload.js — find a require('fs') at top (not allowed in sandboxed preload).
- 09:40Check BrowserWindow config: preload path is './preload.js' (relative, fails in packaged build).
- 09:45Fix: change path to path.join(__dirname, 'preload.js'), remove fs require, add try/catch.
- 09:50Rebuild and test — IPC works, window.electronAPI is defined.
- 09:55Push fix, deploy, monitor — no more IPC failures.
We had a production Electron app that suddenly stopped communicating between main and renderer. The renderer was trying to call window.electronAPI.send, but electronAPI was undefined. No error messages in the main process logs—just silence. The app had been working in dev, so we suspected the packaging step broke something.
I opened the packaged app's renderer DevTools (Ctrl+Shift+I) and immediately saw a TypeError in the console: 'Cannot read property send of undefined'. Typing window.electronAPI returned undefined. This told me the preload script either didn't run or crashed before exposing the API. I checked the preload file in the packaged app's resources—it existed. Then I noticed the preload path in the BrowserWindow config was './preload.js', a relative path. While Electron resolves this relative to the app root in dev, in the packaged asar archive, the path resolution fails silently. The preload script never loaded.
Further inspection of the preload.js source revealed a require('fs') at the top. In Electron's sandboxed preload (default since v22), Node.js built-in modules are not available. This caused a runtime exception that stopped the script, but since it ran before contextBridge.exposeInMainWorld, the API was never exposed. The fix was two-fold: use an absolute path with path.join(__dirname, 'preload.js') and remove the Node.js require. I also wrapped the entire preload in a try/catch that logs to console.error, so future failures are visible. After rebuilding, IPC worked perfectly.
Root cause
Relative preload path caused script to not load in packaged build; require('fs') in preload crashed the script due to sandbox restrictions.
The fix
Changed preload path to absolute with path.join(__dirname, 'preload.js'), removed require('fs'), added try/catch logging.
The lesson
Always use absolute paths for preload scripts and test the packaged build early. Wrap preload in try/catch to surface silent crashes.
Since Electron 12, contextIsolation defaults to true. This means the renderer process runs in a separate JavaScript context from the preload script—they do not share variables or globals. The only way to pass functions or data from preload to renderer is via contextBridge.exposeInMainWorld. If you omit that call, the renderer has no access to ipcRenderer at all, and all IPC calls fail silently. Many devs forget this after upgrading from older Electron versions where nodeIntegration was true.
To inspect the bridge, open DevTools on the renderer and type window.electronAPI (or whatever key you used in exposeInMainWorld). If it's undefined, your preload either didn't run, crashed, or the key is wrong. Also check the preload script's Source in DevTools—if it's not listed, the path is incorrect. A common mistake is setting preload to a relative path like './preload.js'—this works in dev but fails in production because the working directory changes. Always use path.join(__dirname, 'preload.js') or an absolute path.
When the preload script throws an exception (e.g., TypeError, ReferenceError, or missing module), Electron does not surface that error to the renderer console by default. The preload runs before the renderer's window is created, so any error is swallowed. The only symptom is that contextBridge.exposeInMainWorld never executes, leaving window.electronAPI undefined. To catch these, wrap your entire preload code in a try/catch and log to stderr: try { ... } catch(e) { console.error(e); }. This error appears in the renderer DevTools console.
Common preload crashes include: using require('fs') or other Node modules that are not allowed in sandboxed preloads; referencing an undefined variable; or calling ipcRenderer methods before the module is loaded. Also, if you use TypeScript, ensure the compiled preload.js is correct and doesn't contain import statements that fail at runtime. Always test the preload by running the app in dev and checking for any console errors.
Even with a correctly working preload, IPC can fail due to channel name mismatches. A typo like 'my-channel' vs 'my_channel' causes the message to be sent to a listener that doesn't exist. Since there is no error for an unhandled IPC channel, this appears as a silent failure. To debug, add a catch-all listener in main: ipcMain.on('*', (event, channel, ...args) => console.log('IPC:', channel, args)). This logs every incoming message, helping you spot mismatches.
Timing is another culprit. If the main process sends a message via webContents.send before the renderer's IPC listener is registered, the message is lost. Always ensure the renderer's listener is set up before main sends—for example, by having the renderer send a 'ready' message first, or by using 'did-finish-load' event in main to delay sending. Also, if the renderer is a single-page app with navigation, the listener might be destroyed and not re-registered—use persistent listeners on the window object.
IPC that works in development can break after packaging with electron-builder or electron-packager. The most common reason is the preload script path. In development, __dirname points to the project root, but in production, it points to the app.asar directory. A relative path like './preload.js' might resolve to the wrong location or fail to load entirely. Use path.join(__dirname, 'preload.js')—this works in both dev and production. Also ensure the preload file is included in the build's resources (it usually is, but check the builder config).
Another production issue: if you use environment variables or Node modules that are not bundled, they may be missing in the packaged app. For example, using a .env file with dotenv—make sure it's included. Also, if you use native Node modules (like serialport), they need to be rebuilt for the target platform. Test the packaged build early in your CI pipeline to catch these failures before they reach users.
Content Security Policy (CSP) headers or meta tags can block the execution of the preload script or inline scripts in the renderer. Electron sets a default CSP, but if you override it, you might inadvertently block the bridge. For example, setting 'script-src' without 'unsafe-inline' can break contextBridge. Check your CSP in the renderer's network tab or by inspecting the meta tag. If CSP is too restrictive, the preload script may not execute, and you'll see a CSP violation in the console.
Also, if you enable sandbox: true in webPreferences, the preload script runs with limited capabilities. Some APIs like require are disabled. In sandboxed preloads, you can only use a subset of Node.js modules. Ensure your preload doesn't rely on restricted modules. If you need full Node.js access, set sandbox: false, but this has security implications. The secure approach is to design your preload to only use contextBridge and ipcRenderer.
Frequently asked questions
Why does IPC work in dev but not in the production build?
Most often because the preload script path is relative. In dev, __dirname resolves to your project root, but in packaged app, it resolves to the app.asar directory. A relative path like './preload.js' may not find the file. Use path.join(__dirname, 'preload.js') to get an absolute path that works in both environments. Also check that the preload file is included in the build resources.
How do I see preload script errors in the renderer console?
By default, preload errors are not shown. Wrap your entire preload code in a try/catch and log to console.error: try { ... } catch(e) { console.error(e); }. This surfaces errors in the renderer DevTools console. Alternatively, set the Electron command-line flag --enable-logging=file to dump logs to a file.
Can I use ipcRenderer directly in the renderer without a preload?
Only if you set contextIsolation: false and nodeIntegration: true in webPreferences. This is strongly discouraged for security reasons because it exposes Node.js APIs to the renderer. The recommended approach is to use a preload script with contextBridge to expose only specific IPC functions.
What does contextBridge.exposeInMainWorld actually do?
It adds a property to the renderer's global window object. The first argument is the key (e.g., 'electronAPI'), and the second is an object or function that you want to expose. The renderer can then access it as window.electronAPI. The exposed functions run in the preload's context, so they have access to ipcRenderer and other Node APIs, but the renderer code does not.
How do I debug IPC timing issues—renderer not ready when main sends a message?
Have the renderer send a 'ready' message to main after its IPC listener is set up. Main then waits for that message before sending data. Alternatively, use the 'did-finish-load' event on the BrowserWindow to send messages after the page loads. For single-page apps, ensure listeners are attached on initial load and not removed on navigation.