Build desktop apps for Windows, macOS, and Linux with Electron. Learn the main, renderer, and preload structure, IPC, security settings, local data storage, menus and dialogs, testing, and packaging and distribution with Electron Forge through a memo app example that was actually run.
Intermediate
Updated Oct 10, 2026
~19 min read
16 sections
25 code examples
π₯οΈ
Cross-platform desktop appsMain Β· renderer IPCSecurity settingsPackaging with Electron ForgeAuto-update
Build desktop apps for Windows, macOS, and Linux with Electron. Learn the main, renderer, and preload structure, IPC, security settings, local data storage, menus and dialogs, testing, and packaging and distribution with Electron Forge through a memo app example that was actually run. Rather than listing concepts, this guide is organized so you can follow the content in the order you have to make decisions on a real project.
Key perspective
Frontend / app development
Do not stop at drawing the screen: look at state, data requests, routing, accessibility, and the unit of deployment together.
Cross-platform desktop appsMain Β· renderer IPCSecurity settingsPackaging with Electron ForgeAuto-update
Architecture diagrams
To make what you read stick, it helps to capture the flow as a picture first. The two diagrams below are reference maps you can keep coming back to while learning Electron.
Learning flow
Rendering diagramβ¦
Architecture view
Rendering diagramβ¦
What is Electron?
When you first open Electron, the big picture matters more than individual commands. This section starts with the problems the concepts ahead were created to solve.
Electron is a framework that bundles Chromium (the UI) and Node.js (operating system access) so you can build desktop apps with HTML, CSS, and JavaScript. One codebase produces apps for Windows, macOS, and Linux. The desktop apps of VS Code, Slack, Discord, and Figma are built with Electron.
Every app includes the whole of Chromium and Node.js, so size and memory use are large. Even the small memo app in this guide packages to a zip of about 150MB on Windows. In return, the UI is drawn by the same browser engine on every operating system, so behavior is consistent.
Option
UI engine
Strengths
Watch out for
Electron
Chromium bundled with the app
Same UI everywhere, the Node.js ecosystem as it is, plenty of material and examples
Large size and memory use
Tauri
The operating system's WebView
Small size and low memory use
WebViews differ per operating system so the UI can differ; the backend is Rust
Web app (PWA)
The user's browser
Nothing to install or distribute
Limited access to operating system features such as the file system
Native app
The operating system's UI
Lightest and best fit for the platform
Developed separately for each operating system
Creating the project
Here you look at Creating the project alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
Electron is an npm package. With Node.js installed you can start right away. The entry point of the app is the file named in main in package.json.
This guide builds a small app that saves memos. It has only six files, but it contains every part of the structure an Electron app needs.
InstallBASH
mkdir memo-app && cd memo-appnpm init -ynpm install --save-dev electron
memo-app/
βββ package.json
βββ src/
β βββ main.js main process (the entry point of the app)
β βββ preload.cjs the bridge between main and the page
β βββ notes-store.js memo storage logic (plain code, independent of Electron)
β βββ renderer/
β βββ index.html the page
β βββ renderer.js the page's behavior
β βββ style.css
βββ test/
βββ notes-store.test.js
Process model β main Β· renderer Β· preload
Here you look at Process model β main Β· renderer Β· preload alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
An Electron app runs as several processes. Understanding this structure is most of learning Electron.
Main process: there is exactly one per app. It runs in a Node.js environment, creates windows, and handles operating system features such as files, menus, and dialogs.
Renderer process: there is one per window. It is a browser environment that draws a web page and, with default settings, has no access to Node.js or operating system features.
Preload script: a small script that runs in the renderer before the page. It is the bridge that selects functions for sending requests to main and exposes them to the page.
The page is kept from reading files directly for security. A page may display content that came from outside, and that content must not be able to read the user's files or run programs. So only main touches operating system features, and the page sends only the requests it is allowed to.
Node.js features such as require and file access (default settings)
Preload script
An isolated area inside the renderer
Wrap requests to main as functions and expose them to the page
Access the page's global variables directly
The main process and windows
Here you look at The main process and windows alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
Once the app is ready (app.whenReady()), the main process creates a window and loads the page file. A window is a BrowserWindow, and webPreferences.preload takes the path of the preload script.
Operating systems differ in how apps quit. On Windows and Linux the app ends when every window is closed; on macOS the app stays in the Dock after its windows close and reopens a window when the icon is clicked. The window-all-closed and activate handlers in the code below cover that difference.
src/main.jsJAVASCRIPT
import { app, BrowserWindow, ipcMain, shell } from 'electron';import { join } from 'node:path';import { pathToFileURL } from 'node:url';import { createNotesStore } from './notes-store.js';const RENDERER_DIR = join(import.meta.dirname, 'renderer');const RENDERER_URL = pathToFileURL(join(RENDERER_DIR, 'index.html')).href;let store;function createWindow() { const win = new BrowserWindow({ width: 900, height: 640, show: false, webPreferences: { preload: join(import.meta.dirname, 'preload.cjs'), // These three are the defaults, stated explicitly so they are not changed by mistake contextIsolation: true, sandbox: true, nodeIntegration: false, }, }); // Showing the window after the page is ready avoids a white flash win.once('ready-to-show', () => win.show()); // Do not let the app navigate to another address win.webContents.on('will-navigate', (event, url) => { if (url !== RENDERER_URL) event.preventDefault(); }); // Never create new windows; open only https links in the default browser win.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('https://')) shell.openExternal(url); return { action: 'deny' }; }); win.loadFile(join(RENDERER_DIR, 'index.html')); return win;}// (the IPC registration code is added in the next section)app.whenReady().then(() => { store = createNotesStore(join(app.getPath('userData'), 'notes.json')); registerIpc(); createWindow(); // macOS: recreate the window when the Dock icon is clicked and none is open app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });});// Windows Β· Linux: quit the app when every window is closedapp.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit();});
IPC β talking between the page and main
Here you look at IPC β talking between the page and main alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
The page (renderer) and main are different processes, so they cannot call each other's functions; they exchange messages. This is IPC (inter-process communication). The most used form is invoke / handle: send a request and wait for the result.
The flow is split across three places. Main registers a handler under a channel name (ipcMain.handle), preload exposes a function that calls that channel to the page (contextBridge), and the page calls that function.
src/main.js β IPC registrationJAVASCRIPT
// Check that the page sending the request is our own pagefunction assertTrustedSender(event) { if (event.senderFrame?.url !== RENDERER_URL) throw new Error('Request not allowed');}function registerIpc() { ipcMain.handle('notes:list', (event) => { assertTrustedSender(event); return store.list(); }); ipcMain.handle('notes:add', (event, input) => { assertTrustedSender(event); return store.add(input); // input is validated inside store.add }); ipcMain.handle('notes:remove', (event, id) => { assertTrustedSender(event); if (typeof id !== 'string') throw new TypeError('Invalid id'); return store.remove(id); });}
src/preload.cjsJAVASCRIPT
const { contextBridge, ipcRenderer } = require('electron');// Expose only the functions the page (renderer) needs, each under a name.// Handing over ipcRenderer itself would let page code call any channel.contextBridge.exposeInMainWorld('notes', { list: () => ipcRenderer.invoke('notes:list'), add: (note) => ipcRenderer.invoke('notes:add', note), remove: (id) => ipcRenderer.invoke('notes:remove', id),});
Notifying the page from main (the other direction)JAVASCRIPT
// main.js β tell the page when a menu item is clickedwin.webContents.send('menu:new-note');// preload.cjs β wrap it in a function so the page can receive the notificationcontextBridge.exposeInMainWorld('appEvents', { onNewNote: (callback) => ipcRenderer.on('menu:new-note', () => callback()),});// renderer.jswindow.appEvents.onNewNote(() => document.querySelector('#title').focus());
Direction
API
When to use
Page β main (with a result)
ipcRenderer.invoke / ipcMain.handle
Most requests: reading files, saving, queries
Page β main (no result)
ipcRenderer.send / ipcMain.on
Commands that need no reply, such as minimizing the window
Main β page
webContents.send / ipcRenderer.on
Menu selections, download progress, update notices
Renderer β building the page
Here you look at Renderer β building the page alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
The page is the same as an ordinary web page. Build it with HTML and CSS and make requests to main through the window.notes that preload exposed. Frameworks such as React and Vue work as they are.
Put a Content-Security-Policy in the HTML so only scripts bundled with the app run. When showing user input on the page, use textContent rather than innerHTML so the input is not interpreted as HTML.
Here you look at Security settings alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
Unlike a browser, the page of an Electron app has a channel to the operating system (IPC). If a malicious script runs on the page, it can reach the user's files through that channel, so the settings have to be stricter than for a website.
There are three principles. Do not give the page Node.js, do not trust values that come from the page, and load only the content the app intends.
Settings you must not useJAVASCRIPT
new BrowserWindow({ webPreferences: { nodeIntegration: true, // the page can require('child_process') β forbidden contextIsolation: false, // the page can tamper with preload's objects β forbidden sandbox: false, // removes the renderer's isolation β forbidden webSecurity: false, // removes the same-origin policy β forbidden },});// Exposing the whole ipcRenderer from preload is also forbiddencontextBridge.exposeInMainWorld('ipc', ipcRenderer);
Item
Setting
Why
contextIsolation
true (default)
Separates the global scope of preload from the page so page code cannot touch Electron internals.
sandbox
true (default)
Keeps the renderer process from accessing operating system resources directly.
nodeIntegration
false (default)
Keeps the page from using Node.js modules through require.
webSecurity
true (default)
Keeps the same-origin policy. Do not turn it off for development convenience.
Content-Security-Policy
script-src 'self'
Runs only scripts bundled with the app and blocks inline and external scripts.
IPC input validation
Check type Β· length Β· range in main
Values sent by the page can be tampered with.
IPC sender check
Check event.senderFrame.url
Rejects requests from frames that are not the app's page.
Navigation Β· new window limits
will-navigate, setWindowOpenHandler
Keeps the app window from turning into an arbitrary website.
shell.openExternal
Allow https addresses only
Passing values through unchecked can run arbitrary programs or files.
Remote content
https only, local files only where possible
Do not attach preload to a window that loads an external page.
Storing data locally
Here you look at Storing data locally alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
Store the app's data in the folder that app.getPath('userData') returns. It is the per-user application data location defined by each operating system, and it survives app updates. The folder the app is installed in may not be writable, so do not store data there.
Keep the storage logic in a plain module that does not import Electron. If it takes just a file path as an argument, it can be tested with Node.js alone, without Electron.
src/notes-store.jsJAVASCRIPT
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';import { dirname } from 'node:path';const MAX_TITLE = 100;const MAX_BODY = 10_000;// Values from the page are not trusted: check shape and length before savingexport function validateNote(input) { if (typeof input !== 'object' || input === null) throw new TypeError('Invalid note format'); const title = typeof input.title === 'string' ? input.title.trim() : ''; const body = typeof input.body === 'string' ? input.body : ''; if (!title) throw new TypeError('Enter a title'); if (title.length > MAX_TITLE) throw new TypeError(`The title must be at most ${MAX_TITLE} characters`); if (body.length > MAX_BODY) throw new TypeError(`The body must be at most ${MAX_BODY} characters`); return { title, body };}export function createNotesStore(filePath) { async function readAll() { try { return JSON.parse(await readFile(filePath, 'utf8')); } catch (err) { if (err.code === 'ENOENT') return []; // first run: the file does not exist yet throw err; } } async function writeAll(notes) { await mkdir(dirname(filePath), { recursive: true }); // Write to a temporary file, then rename: the existing file is not corrupted if the app quits mid-write const tmp = `${filePath}.tmp`; await writeFile(tmp, JSON.stringify(notes, null, 2)); await rename(tmp, filePath); } return { list: readAll, async add(input) { const note = { id: crypto.randomUUID(), ...validateNote(input), createdAt: new Date().toISOString() }; const notes = await readAll(); notes.unshift(note); await writeAll(notes); return note; }, async remove(id) { const notes = await readAll(); await writeAll(notes.filter((note) => note.id !== id)); }, };}
Operating system
userData location
Windows
%APPDATA%\AppName
macOS
~/Library/Application Support/AppName
Linux
~/.config/AppName
Menus Β· dialogs Β· tray
Here you look at Menus Β· dialogs Β· tray alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
Operating system features are all handled in the main process. When the page needs one, it asks over IPC, as before.
A menu is defined as a template array. Using role adds standard actions and shortcuts such as copy, paste, and quit in the form each operating system expects. A dialog has the user pick the file, which is safer than letting the page read an arbitrary path.
import { dialog } from 'electron';import { readFile } from 'node:fs/promises';ipcMain.handle('file:import', async (event) => { assertTrustedSender(event); const { canceled, filePaths } = await dialog.showOpenDialog({ title: 'Choose a memo to import', properties: ['openFile'], filters: [{ name: 'Text', extensions: ['txt', 'md'] }], }); if (canceled) return null; // Use only the path the user picked in the dialog (the page never sends a path) return { name: filePaths[0], text: await readFile(filePaths[0], 'utf8') };});
Tray icon and notificationsJAVASCRIPT
import { Tray, Menu, Notification, nativeImage } from 'electron';let tray; // as a local variable it would be garbage collected and the icon would disappearfunction createTray(win) { tray = new Tray(nativeImage.createFromPath(join(import.meta.dirname, 'tray.png'))); tray.setToolTip('Memo'); tray.setContextMenu(Menu.buildFromTemplate([ { label: 'Open', click: () => win.show() }, { role: 'quit', label: 'Quit' }, ]));}new Notification({ title: 'Memo', body: 'Memo saved' }).show();
Debugging
Here you look at Debugging alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
With two processes there are two places to debug. Page problems are examined with DevTools, exactly as in a browser, and main process problems with the terminal output and the Node.js debugger.
Open DevTools only during developmentJAVASCRIPT
// app.isPackaged is true in a packaged appif (!app.isPackaged) { win.webContents.openDevTools({ mode: 'detach' });}
Target
How
What you see
Renderer (page)
Ctrl+Shift+I in the window (Cmd+Option+I on macOS), or win.webContents.openDevTools()
console.log output, errors and stacks from IPC handlers
Main process (breakpoints)
electron --inspect=5858 . then connect from chrome://inspect
Breakpoints, variables, call stack
Preload
The renderer's DevTools
console.log output and errors from preload appear in the page's Console.
Testing
Here you look at Testing alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
Split the tests of an Electron app into two layers. Logic such as storage, validation, and calculation goes into modules that do not import Electron and is tested quickly with Node.js alone, and page behavior that needs a window is checked with a small number of E2E tests.
Below is the test for the storage module built earlier. It uses a temporary folder, so it never touches real user data, and it finishes quickly because Electron is not started.
test/notes-store.test.jsJAVASCRIPT
import { test } from 'node:test';import assert from 'node:assert/strict';import { mkdtemp, rm } from 'node:fs/promises';import { tmpdir } from 'node:os';import { join } from 'node:path';import { createNotesStore, validateNote } from '../src/notes-store.js';test('a saved memo appears first in the list', async (t) => { const dir = await mkdtemp(join(tmpdir(), 'memo-')); t.after(() => rm(dir, { recursive: true, force: true })); const store = createNotesStore(join(dir, 'notes.json')); assert.deepEqual(await store.list(), []); await store.add({ title: 'First memo', body: '' }); const second = await store.add({ title: ' Second memo ', body: 'Body' }); const notes = await store.list(); assert.equal(notes.length, 2); assert.equal(notes[0].title, 'Second memo'); await store.remove(second.id); assert.equal((await store.list()).length, 1);});test('rejects a missing title or a wrong format', () => { assert.throws(() => validateNote({ title: ' ' }), /Enter a title/); assert.throws(() => validateNote('a string'), /Invalid note format/); assert.throws(() => validateNote({ title: 'a'.repeat(101) }), /at most 100 characters/);});
RunBASH
npm test# β a saved memo appears first in the list# β rejects a missing title or a wrong format# βΉ pass 2# βΉ fail 0
E2E test (Playwright)JAVASCRIPT
// npm install --save-dev @playwright/testimport { test, expect, _electron as electron } from '@playwright/test';test('an added memo shows up in the list', async () => { const app = await electron.launch({ args: ['.'] }); const page = await app.firstWindow(); await page.fill('#title', 'E2E memo'); await page.click('button'); await expect(page.locator('#list li')).toContainText('E2E memo'); await app.close();});
Packaging β Electron Forge
Here you look at Packaging β Electron Forge alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
To hand the app to users, it has to be bundled into an executable. Electron Forge is the official tool maintained by the Electron project and handles packaging (package) and building installers (make) in one go.
Packaging produces output for the operating system it runs on. The rule is to build the Windows version on Windows and the macOS version on macOS, usually per operating system in CI.
export default { packagerConfig: { asar: true, // bundle the app source into a single app.asar file ignore: [/^\/test($|\/)/], // paths to leave out of the package }, makers: [ { name: '@electron-forge/maker-zip' }, ],};
npm run make# β Making a zip distributable for win32/x64# βΊ Artifacts available at: out/make## out/Memo-win32-x64/ runnable app folder (Memo.exe, resources/app.asar)# out/make/zip/win32/x64/ Memo-win32-x64-1.0.0.zip for distribution
Maker
Output
Target
@electron-forge/maker-zip
Zip archive
Every operating system (simplest)
@electron-forge/maker-squirrel
Installer (Setup.exe)
Windows
@electron-forge/maker-dmg
.dmg disk image
macOS
@electron-forge/maker-deb
.deb package
Debian Β· Ubuntu
@electron-forge/maker-rpm
.rpm package
Fedora Β· RHEL
Distribution β code signing and auto-update
Here you look at Distribution β code signing and auto-update alongside real code. Rather than copying the example as-is, read it with an eye on the inputs, the outputs, and the parts most likely to change.
If you distribute the installer as it is, the operating system shows an "unidentified developer" warning. Installing without a warning requires code signing for each operating system. This involves obtaining certificates and paying for them, so it was not run as an example here. When you apply it, follow the official Electron and Electron Forge documentation.
The basic auto-update flowJAVASCRIPT
import { autoUpdater, dialog } from 'electron';// Works only in a packaged appif (app.isPackaged) { autoUpdater.setFeedURL({ url: 'https://updates.example.com/memo/' + process.platform + '/' + app.getVersion() }); autoUpdater.on('update-downloaded', async () => { const { response } = await dialog.showMessageBox({ message: 'A new version has been downloaded. Restart now?', buttons: ['Restart', 'Later'], }); if (response === 0) autoUpdater.quitAndInstall(); }); autoUpdater.checkForUpdates();}
Operating system
What you need
Without signing
Windows
A code signing certificate
A SmartScreen warning appears and the user has to allow the app to run.
macOS
An Apple Developer account, signing and notarization
Gatekeeper blocks the app. Auto-update does not work either.
Linux
Not required
Follow the package repository policy of the distribution.
Performance and memory
Performance and memory is a point where the options diverge. Using the table to compare what each approach is for and how it differs in operation makes later decisions much easier.
The opinion that Electron apps are slow mostly comes from two things: slow startup and a UI that freezes. Both have known causes.
Symptom
Cause
Fix
Slow startup
All modules are loaded at once at startup.
Load modules that are not needed yet when they are used (lazy loading), and bundle page code with a bundler.
A white screen at startup
The window is shown before the page is drawn.
Create it with show: false and show it on ready-to-show.
The whole window freezes
Long synchronous work runs in the main process.
Switch to async functions and move CPU work to a utilityProcess or a Worker.
Interaction stutters
The renderer does heavy computation or draws a huge list.
Move computation to a Web Worker and render only the visible part of long lists.
Memory keeps growing
References to closed windows or listeners that were never removed remain.
Drop references when a window closes and remove listeners added with ipcRenderer.on.
IPC is slow
Large data is exchanged often.
Send only what is needed and handle large files by path or as streams.
Common errors
Common errors is a point where the options diverge. Using the table to compare what each approach is for and how it differs in operation makes later decisions much easier.
Most Electron errors come from mixing up "which process does this code run in". Start by checking whether the error appeared in the page's Console or in the terminal.
Symptom Β· error
Cause
Fix
npm start opens no window and behaves like Node.js
The ELECTRON_RUN_AS_NODE environment variable is set. It happens often in the VS Code terminal.
Remove the variable in the terminal and run again (bash: unset ELECTRON_RUN_AS_NODE, PowerShell: Remove-Item Env:ELECTRON_RUN_AS_NODE).
The app hangs at startup (ES Modules)
await app.whenReady() was used at the top level of the main file.
Change it to app.whenReady().then(...).
require is not defined (page)
The page tried to use a Node.js module.
Build the feature in main and call it over IPC through preload. Do not turn on nodeIntegration.
window.notes is undefined
Preload did not run.
Check that the preload path is absolute, that the file extension is .cjs, and that the DevTools Console shows no preload error.
Cannot use import statement outside a module (preload)
ES Modules were used in a sandboxed preload.
Make preload a .cjs file and use require.
Cannot create BrowserWindow before app is ready
A window was created before the ready event.
Move the window creation code after app.whenReady().
An object could not be cloned
A function, DOM element, or class instance was sent over IPC.
Send only strings, numbers, arrays, and plain objects.
No handler registered for 'channel'
The channel was not registered with ipcMain.handle, or the name differs.
Check the spelling of the channel name and that it was registered before the window was created.
The window shows only a white screen
The page file path is wrong, or CSP blocked the script.
Check the Console and Network tabs in DevTools. Build the loadFile path from import.meta.dirname.
Refused to execute inline script
CSP blocked an inline script.
Move the script to a separate .js file. Do not loosen the CSP.
Cannot find module in the packaged app
A required package is in devDependencies or matched the ignore setting.
Move packages needed at runtime to dependencies.
Works in development but files are not found after packaging
A path relative to the working directory (process.cwd()) was used.
Find app files relative to import.meta.dirname and user data relative to app.getPath("userData").
Electron: frequently asked questions
Should I choose Electron or Tauri?
Electron bundles Chromium and Node.js with the app, so size and memory use are large, but the UI behaves the same on every operating system and the whole Node.js ecosystem is available. Tauri uses the operating system's WebView, so it is much lighter, but rendering can differ between operating systems and the backend is written in Rust. Electron fits a team that knows the web and Node.js and does not mind the size; Tauri fits when a light app matters and the team can work in Rust.
How does the screen (renderer) read a file in Electron?
It does not read the file itself; it asks the main process. Register the file-reading function in main with ipcMain.handle, expose a function that calls it from the preload script with contextBridge, and have the screen call that function. Turning on nodeIntegration and using fs directly from the screen is not recommended for security reasons.
Why are Electron apps so large?
Every app includes the whole Chromium browser engine and the Node.js runtime. Even an app with a few lines of code usually exceeds 100MB compressed. Shrinking your own code changes little; if size is an important requirement, look at a framework that uses the operating system's WebView.
Next steps
Next steps is a point where the options diverge. Using the table to compare what each approach is for and how it differs in operation makes later decisions much easier.
If you have come this far, you have followed the whole flow once: structuring an Electron app, communicating safely, testing, and packaging. Widen out from here as you need.
What you want to do
Guide to read next
Understand Node.js, the base of the main process, in depth