Fix: Electron 'require' Is Not Defined Error
Part of: JavaScript & TypeScript Errors
Quick Answer
Fix the Electron 'require is not defined' error caused by contextIsolation, nodeIntegration changes, and learn to use preload scripts and contextBridge.
require Is Not Defined
The first time I hit this, I did what almost everyone does: flipped nodeIntegration: true, watched the error vanish, and moved on. That was a mistake I later had to undo across a whole app. This error is not a bug to silence; it is Electron telling you the renderer is now sandboxed like a normal web page, on purpose, and that require belongs in the main process behind a preload bridge. Once I stopped fighting that and learned the preload + contextBridge pattern, the error stopped being an obstacle and became a reminder of where the security boundary sits.
You open your Electron app and the renderer process throws:
Uncaught ReferenceError: require is not definedOr when trying to use Node.js modules in the browser window:
const fs = require('fs'); // ReferenceError: require is not defined
const { ipcRenderer } = require('electron'); // Same errorThis worked in early Electron, but the renderer has been locked down by default since Electron 5 (no nodeIntegration) and fully isolated since Electron 12 (contextIsolation).
Why the Renderer Lost require
Two defaults combine to produce this error. nodeIntegration has defaulted to false since Electron 5, and contextIsolation has defaulted to true since Electron 12. With both in effect, the renderer process (your browser window) runs like a regular web page, it doesn’t have access to Node.js APIs like require, fs, path, or process.
This was a security change. Previously, any JavaScript running in the renderer (including third-party scripts, ads, or injected code) had full access to the filesystem and operating system through Node.js. This was a major security risk.
The solution is to use preload scripts and the contextBridge API to selectively expose only the Node.js functionality your renderer needs.
The first instinct is almost always to set nodeIntegration: true and move on. That works, the error disappears, and the renderer can call require again, but you have just reverted a security default that the Electron team spent years rolling out. Any HTML you load (including a CDN link that goes stale and gets squatted, or any future XSS in your own code) now has the ability to read the user’s home directory, run arbitrary binaries, and exfiltrate data over the network. The correct fix is not to disable the protection; it is to use the preload + contextBridge pattern, which gives the renderer exactly the capabilities you choose to grant and nothing else.
The second confusing layer is that Electron’s defaults have shifted across versions. Electron 5 disabled nodeIntegration. Electron 12 enabled contextIsolation. Electron 14 removed the legacy remote module. Electron 20 enabled the OS-level sandbox by default. Tutorials written before each of these changes look correct but no longer work, and copy-pasting a webPreferences block from an old Stack Overflow answer is the easiest way to end up with a half-broken security posture. Always check the version against the Electron breaking-changes page before trusting a snippet.
Diagnostic Timeline
When the ReferenceError fires the first time, walk through this list instead of jumping to nodeIntegration: true.
Minute 0: Confirm the version. Run npx electron --version. If you are on 12 or higher, contextIsolation is on and nodeIntegration is off by default. The error is expected, and the fix is architectural, not a one-line toggle.
Minute 2: Verify the preload script is actually loading. Add console.log('preload loaded') at the top of preload.js. The line appears in the main process terminal, not the renderer DevTools. If you see nothing, the preload path is wrong or the file errored before reaching that line.
Minute 5: Confirm the absolute path. Open the main process file and check that webPreferences.preload uses path.join(__dirname, 'preload.js'), never a bare string, never a relative path. Packaged apps run from app.asar and resolve ./preload.js to a different directory than development does.
Minute 8: Test the bridge. Add contextBridge.exposeInMainWorld('debug', { ping: () => 'pong' }) to the preload, then in the renderer DevTools console type window.debug.ping(). If this returns 'pong', the preload is wired up correctly and the error is just that your code is trying to use require directly instead of going through the bridge.
Minute 12: Audit the renderer for direct Node calls. Search the renderer source for require(. Every match is a place you need to either remove or replace with a call to window.electronAPI.something() that you defined in the preload.
Minute 15: Check for TypeScript misconfiguration. If the preload is written in TypeScript and your tsconfig.json emits ESM ("module": "esnext") into a .js file, the compiled preload uses import statements that a CommonJS preload context cannot evaluate. The file loads, errors silently, and the renderer sees no window.electronAPI. Compile the preload to CommonJS ("module": "commonjs"), or, if you specifically want an ESM preload on Electron 28+, emit a .mjs file and keep the window unsandboxed with contextIsolation on (see Fix 6).
Minute 18: Rule out webview and BrowserView. A <webview> tag has its own preload attribute, completely independent of the parent window’s webPreferences.preload. Same for BrowserView. If the error is happening inside one of those, the parent’s preload does not apply.
Fix 1: Use a Preload Script with contextBridge
This is the recommended approach. Create a preload script that exposes specific APIs to the renderer:
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (path) => ipcRenderer.invoke('read-file', path),
writeFile: (path, data) => ipcRenderer.invoke('write-file', path, data),
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback),
});Register it in your BrowserWindow:
// main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const fs = require('fs');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // Default in Electron 12+
nodeIntegration: false, // Default in Electron 12+
},
});
win.loadFile('index.html');
}
// Handle IPC calls from the renderer
ipcMain.handle('read-file', async (event, filePath) => {
return fs.readFileSync(filePath, 'utf-8');
});
ipcMain.handle('write-file', async (event, filePath, data) => {
fs.writeFileSync(filePath, data);
});In your renderer:
// renderer.js (loaded by index.html)
const content = await window.electronAPI.readFile('/path/to/file');
console.log(content);Resist the temptation to expose a whole Node.js module through contextBridge. Expose the specific functions the renderer actually calls and nothing more. This is the principle of least privilege in practice: if the renderer is ever compromised by XSS, the attacker inherits exactly the surface you handed it, so a tight readConfig() is a far smaller blast radius than a raw fs object.
Fix 2: Understand the Security Model
Before using any workaround, understand why the defaults changed:
| Setting | Old Default | New Default (12+) | Purpose |
|---|---|---|---|
nodeIntegration | true | false | Prevents Node.js access in renderer |
contextIsolation | false | true | Isolates preload from renderer |
sandbox | false | true (20+) | OS-level process sandboxing |
With contextIsolation: true, the preload script runs in a separate JavaScript context from the renderer. This means:
- The renderer can’t access
requireor any Node.js APIs - The renderer can’t modify the preload script’s globals
- The preload script uses
contextBridgeto create a controlled API
This protects your app from XSS attacks. If an attacker injects JavaScript into your renderer, they can only call the functions you exposed through contextBridge, not arbitrary Node.js code.
Fix 3: Use IPC for Main-Renderer Communication
The Inter-Process Communication (IPC) pattern replaces direct Node.js usage in the renderer:
// main.js — handle requests from renderer
const { ipcMain, dialog } = require('electron');
ipcMain.handle('open-file-dialog', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
return result.filePaths;
});
ipcMain.handle('get-app-version', () => {
return app.getVersion();
});// preload.js — bridge between main and renderer
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
openFileDialog: () => ipcRenderer.invoke('open-file-dialog'),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
});// renderer.js
document.getElementById('open-btn').addEventListener('click', async () => {
const files = await window.electronAPI.openFileDialog();
console.log('Selected:', files);
});Use ipcMain.handle / ipcRenderer.invoke for request-response patterns. Use ipcMain.on / ipcRenderer.send for fire-and-forget messages.
Fix 4: Enable nodeIntegration (Not Recommended)
If you’re building an internal tool or prototype and security isn’t a concern, you can re-enable the old behavior:
const win = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});This restores require in the renderer, but:
- Any loaded URL can execute Node.js code, including remote content
- XSS vulnerabilities become Remote Code Execution vulnerabilities
- This approach is explicitly discouraged by the Electron security guidelines
Only use this for:
- Quick prototypes you’ll rewrite
- Internal tools that never load external content
- Legacy apps during migration to the preload pattern
A subtle trap when you do take the nodeIntegration shortcut: setting nodeIntegration: true alone is not enough on Electron 12+, because contextIsolation still defaults to true and keeps the renderer in a separate context without require. You have to flip both, nodeIntegration: true and contextIsolation: false, which is exactly why this shortcut undoes two security layers, not one.
Fix 5: Fix Preload Script Path Issues
The preload script must be specified as an absolute path:
// Wrong - relative path may not resolve correctly
webPreferences: {
preload: './preload.js'
}
// Wrong - __dirname might not be what you expect in packaged app
webPreferences: {
preload: __dirname + '/preload.js'
}
// Correct - use path.join for cross-platform compatibility
const path = require('path');
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}For packaged apps using asar archives, the preload path must account for the asar structure:
// In development
preload: path.join(__dirname, 'preload.js')
// In production (if using app.asar)
preload: path.join(app.getAppPath(), 'preload.js')Verify your preload script is being loaded by adding a console log:
// preload.js
console.log('Preload script loaded!');Check the main process console, preload logs appear there, not in the renderer’s DevTools.
Fix 6: Handle ES Modules in Electron
If your renderer uses ES modules (type="module" in package.json or <script type="module">), the import syntax differs:
<!-- index.html -->
<script type="module">
// Can't use require() here even with nodeIntegration
// ES modules in the renderer use the window API from contextBridge
const version = await window.electronAPI.getAppVersion();
</script>Preload scripts default to CommonJS, and a sandboxed preload (the default since Electron 20) always runs as plain CommonJS with no ESM loader at all:
// preload.js — CommonJS, works in every Electron version
const { contextBridge } = require('electron');Since Electron 28, an unsandboxed preload with contextIsolation enabled can use ESM, but only if you give the file the .mjs extension. Preload scripts ignore the "type": "module" field in package.json, so the extension is the only switch that matters:
// preload.mjs — ESM, requires sandbox:false and contextIsolation:true on the window
import { contextBridge } from 'electron';If your preload is sandboxed (the Electron 20+ default), ESM is not available there at all, so stay on CommonJS. Reach for an ESM preload only when you specifically need top-level await or an ESM-only dependency, and accept that you must also set sandbox: false, which gives up one layer of protection.
If your main process uses ESM ("type": "module"):
// main.mjs
import { app, BrowserWindow } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));Fix 7: Migrate from remote Module
The remote module was removed in Electron 14. If your code uses it:
// Old way (removed)
const { dialog } = require('electron').remote;
dialog.showOpenDialog(options);Migrate to IPC:
// main.js
ipcMain.handle('show-dialog', async (event, options) => {
return dialog.showOpenDialog(options);
});
// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
showDialog: (options) => ipcRenderer.invoke('show-dialog', options),
});
// renderer.js
const result = await window.electronAPI.showDialog({
properties: ['openFile'],
});If you need remote temporarily during migration, install the community package:
npm install @electron/remote// main.js
require('@electron/remote/main').initialize();
require('@electron/remote/main').enable(win.webContents);This is a stopgap. Plan to migrate all remote usage to IPC.
Fix 8: Handle Electron Version-Specific Breaking Changes
Key version changes that affect require:
Electron 5: nodeIntegration defaults to false Electron 12: contextIsolation defaults to true Electron 14: remote module removed Electron 20: sandbox defaults to true
Check your Electron version:
npx electron --versionIf upgrading across multiple major versions, address each change:
// Electron 20+ compatible configuration
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
// These are all defaults, listed for clarity:
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});With sandbox: true (Electron 20+), even the preload script has limited Node.js access. You can use require for Electron modules (electron) but not arbitrary Node.js modules. Move all Node.js work to the main process and communicate via IPC.
Less Obvious Reasons require Stays Broken
Check for multiple BrowserWindows. Each window needs its own preload configuration. A preload script set on one window doesn’t apply to others.
Verify the preload script compiles. Syntax errors in the preload script fail silently. Test it with
node preload.jsto check for errors.Check TypeScript compilation. If using TypeScript, ensure the preload script is compiled to CommonJS, not ESM. Set
"module": "commonjs"in your tsconfig for the preload.Look for webview tags.
<webview>elements have their own preload attribute. The main window’s preload doesn’t apply to webviews.Clear the application cache. Old cached versions of your app may still use outdated preload scripts. Clear
userDatadirectory during development.Test in a clean environment. Create a minimal Electron app with just a main process, preload, and renderer to isolate the issue from your application code.
Inspect what the renderer actually sees. Type
Object.keys(window)in the renderer DevTools. If the keys you exposed viacontextBridge.exposeInMainWorldare not present, the preload either errored out, was never registered, or registered against the wrong window. Confirm by adding a deliberate throw inside the preload and checking the main process logs.Beware bundler externals when shipping a preload through webpack. A webpack-bundled preload that imports
electronas a regular dependency tries to resolve it through node_modules at runtime and fails. Markelectronas an external (externals: { electron: 'commonjs electron' }) so the preload uses Electron’s built-in module.Check for Electron Forge or electron-builder packaging mistakes. A common mistake is excluding the preload file from the packaged build because it lives outside the
src/directory listed in your packager config. Open the generatedapp.asar(npx asar list app.asar) and verifypreload.jsis present.Rule out unhandled rejections inside the preload. A rejected promise at the top level of the preload script aborts the load on some Electron versions without printing a clear error. Wrap top-level awaits in try/catch and log the error explicitly.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Deno PermissionDenied — Missing --allow-read, --allow-net, and Other Flags
How to fix Deno PermissionDenied (NotCapable in Deno 2) errors — the right permission flags, path-scoped permissions, deno.json permission sets, and the Deno.permissions API.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Kafka Consumer Not Receiving Messages, Connection Refused, and Rebalancing Errors
How to fix Apache Kafka issues — consumer not receiving messages, auto.offset.reset, Docker advertised.listeners, max.poll.interval.ms rebalancing, MessageSizeTooLargeException, and KafkaJS errors.
Fix: OpenAI API Not Working — RateLimitError, 401, 429, and Connection Issues
How to fix OpenAI API errors — RateLimitError (429), AuthenticationError (401), APIConnectionError, context length exceeded, model not found, and SDK v0-to-v1 migration mistakes.