Skip to main content
AIDevOps
  • Learn
  • Learning Paths
  • Practice
  • Open Source
  • Books
  • Engineering

    AI DevOpsThe full map of building and operating AI servicesLLMOpsLLM deployment Β· evaluation Β· observabilityHands-on ProjectsBuild AI Agent projects

    Knowledge

    DocsTechnical documentationBlogEngineering articlesPloggerDevelopment log feed

    Validate

    Certification3-level skills certification Β· coming soon
AI Models
LlamaTutorial|MistralTutorial|GemmaTutorial|DeepSeekTutorial|QwenTutorial
🎨 Frontend
Frontend Intro & RoadmapJavaScriptTypeScript|ReactNext.js|VueNuxt|Electron
πŸ€– AI Applied Development
Applied AI Intro & RoadmapHugging FaceLangChainLlamaIndexLLMOps|LangGraphMCPMulti-AgentAgent Evaluation
🧠 AI Core
AI Intro & RoadmapML FundamentalsLLM Fundamentals|Python AIC++|PyTorchTensorFlowJAX
🧠 AI Agent Development
Finance AI AgentLLM API ServerStock Investing AgentAIOps AI AgentEducation AI AgentCoding AI Agent
🌱 Spring Cloud
Spring Intro & RoadmapSpring Cloud GatewaySpring BootJava|Spring AISpring SecuritySpring BatchSpring JPA
🐳 DevOps
DevOps Intro & RoadmapLinuxDockerCI/CD|Kubernetes BasicsK8s AdvancedPrometheusGrafana
🧱 Infrastructure
Infrastructure Intro & RoadmapNginxRedis
☁️ Cloud
Cloud Intro & RoadmapAWSGCPAzureNCPCloudflare
πŸ“± Mobile
Mobile Intro & RoadmapKotlinAndroidFlutter
βš™οΈ Backend
Backend Intro & RoadmapPython BasicsFastAPIDjangoFlask|CGoGinNode.js
πŸ’Ύ Database
DB Intro & RoadmapCore SQLOracleMySQLPostgreSQL|MongoDBVector DB
πŸ§ͺ Testing
k6JMeternGrinder
AIDevOps

Engineering AI. From Code to Production.
An engineering learning platform for building and operating AI and AI Agents

Learn

  • All Guides
  • Learning Paths
  • AI Tutorial
  • Practice
  • Books

Resources

  • AI DevOps
  • LLMOps
  • Hands-on Projects
  • Docs
  • Blog
  • Plogger
  • Open Source
  • Certification (coming soon)

Start Here

  • AI Core Roadmap
  • AI Applied Development Roadmap
  • Spring Cloud Roadmap
  • DevOps Roadmap
  • Infrastructure Roadmap

Β 

  • Cloud Roadmap
  • Frontend Roadmap
  • Mobile Roadmap
  • Backend Roadmap
  • Database Roadmap
Β© 2026 AIDevOps. All rights reserved.
Terms of ServicePrivacy PolicySitemaptestforge.kr
  1. Home
  2. Learn
  3. Frontend
  4. Electron
Electron Desktop App Development Guide

πŸ–₯️ Electron Complete Guide

Visitors

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

Related frameworks & environments

🟒Node.jsβ†’βœ¨JavaScriptβ†’πŸ”·TypeScriptβ†’βš›οΈReactβ†’πŸ’šVueβ†’

Contents

0 / 19
  1. How to use this guide
  2. Architecture diagrams
  3. What is Electron?
  4. Creating the project
  5. Process model β€” main Β· renderer Β· preload
  6. The main process and windows
  7. IPC β€” talking between the page and main
  8. Renderer β€” building the page
  9. Security settings
  10. Storing data locally
  11. Menus Β· dialogs Β· tray
  12. Debugging
  13. Testing
  14. Packaging β€” Electron Forge
  15. Distribution β€” code signing and auto-update
  16. Performance and memory
  17. Common errors
  18. Frequently asked questions
  19. Next steps
Contents 19 sections
  1. How to use this guide
  2. Architecture diagrams
  3. What is Electron?
  4. Creating the project
  5. Process model β€” main Β· renderer Β· preload
  6. The main process and windows
  7. IPC β€” talking between the page and main
  8. Renderer β€” building the page
  9. Security settings
  10. Storing data locally
  11. Menus Β· dialogs Β· tray
  12. Debugging
  13. Testing
  14. Packaging β€” Electron Forge
  15. Distribution β€” code signing and auto-update
  16. Performance and memory
  17. Common errors
  18. Frequently asked questions
  19. Next steps

How to use this guide

How to read it

Understanding Electron as a real-world workflow

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.

OptionUI engineStrengthsWatch out for
ElectronChromium bundled with the appSame UI everywhere, the Node.js ecosystem as it is, plenty of material and examplesLarge size and memory use
TauriThe operating system's WebViewSmall size and low memory useWebViews differ per operating system so the UI can differ; the backend is Rust
Web app (PWA)The user's browserNothing to install or distributeLimited access to operating system features such as the file system
Native appThe operating system's UILightest and best fit for the platformDeveloped separately for each operating system

Tip

  • For a team that knows web technology, needs apps for several operating systems quickly, and does not mind the size, Electron is the safe choice.
  • Electron ships a new major version about every 8 weeks and supports only the latest 3 majors. Security patches arrive with Chromium, so it has to be upgraded regularly.

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-app
npm init -y
npm install --save-dev electron
package.jsonJSON
{
  "name": "memo-app",
  "productName": "Memo",
  "version": "1.0.0",
  "description": "Electron guide example",
  "type": "module",
  "main": "src/main.js",
  "scripts": {
    "start": "electron .",
    "test": "node --test"
  },
  "devDependencies": {
    "electron": "^44.5.1"
  }
}
Folder layoutTEXT
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

Tip

  • Install electron in devDependencies. The packaging tool bundles the Electron executable separately, so it does not belong in the app's dependencies.
  • If npm start opens no window and behaves like Node.js, see the ELECTRON_RUN_AS_NODE entry under "Common errors". It happens often in the VS Code terminal.

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.

Rendering diagram…
PartRuns inCan doCannot do
Main processNode.jsCreate windows, read Β· write files, menus, dialogs, app lifecycleWork with the page (DOM)
Renderer processChromium (browser)Draw the page, handle user inputNode.js features such as require and file access (default settings)
Preload scriptAn isolated area inside the rendererWrap requests to main as functions and expose them to the pageAccess the page's global variables directly

Tip

The answer to "how do I read a file from the page?" is always the same. Build the feature in main, expose a function that calls it from preload, and have the page call that function.

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 closed
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

Tip

  • In a main file written as an ES module, a top-level await app.whenReady() hangs the app at startup, because Electron sends the ready event only after the entry file finishes running. Use app.whenReady().then(...) as in the code above.
  • A BrowserWindow cannot be created before app.whenReady(). Run all window-related code after ready.
  • To keep the app from running twice, call app.quit() when app.requestSingleInstanceLock() returns false, and bring the existing window to the front in the second-instance event.

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 page
function 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),
});
Calling from the pageJAVASCRIPT
const note = await window.notes.add({ title: 'Guide memo', body: 'Body text' });
const notes = await window.notes.list();
console.log(notes.length);   // 1
Notifying the page from main (the other direction)JAVASCRIPT
// main.js β€” tell the page when a menu item is clicked
win.webContents.send('menu:new-note');

// preload.cjs β€” wrap it in a function so the page can receive the notification
contextBridge.exposeInMainWorld('appEvents', {
  onNewNote: (callback) => ipcRenderer.on('menu:new-note', () => callback()),
});

// renderer.js
window.appEvents.onNewNote(() => document.querySelector('#title').focus());
DirectionAPIWhen to use
Page β†’ main (with a result)ipcRenderer.invoke / ipcMain.handleMost requests: reading files, saving, queries
Page β†’ main (no result)ipcRenderer.send / ipcMain.onCommands that need no reply, such as minimizing the window
Main β†’ pagewebContents.send / ipcRenderer.onMenu selections, download progress, update notices

Tip

  • A preload script cannot use ES Modules while the sandbox is on (the default). That is why its extension is .cjs and it uses require.
  • An error thrown in a main handler reaches the page with a prefix, as in "Error invoking remote method 'notes:add': TypeError: Enter a title". When you need a message to show the user, returning a result object such as { ok: false, message } is cleaner.
  • Functions, DOM elements, and class instances cannot be sent over IPC. Exchange only values that can be copied: strings, numbers, arrays, and plain objects.
  • Naming channels in an "area:action" form such as notes:add keeps them easy to manage.

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.

src/renderer/index.htmlHTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'">
  <title>Memo</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <form id="form">
    <input id="title" placeholder="Title" maxlength="100" required>
    <textarea id="body" placeholder="Body"></textarea>
    <button>Save</button>
  </form>
  <p id="error" role="alert"></p>
  <ul id="list"></ul>
  <script src="renderer.js"></script>
</body>
</html>
src/renderer/renderer.jsJAVASCRIPT
const form = document.querySelector('#form');
const list = document.querySelector('#list');
const error = document.querySelector('#error');

async function render() {
  const notes = await window.notes.list();
  list.replaceChildren(...notes.map((note) => {
    const item = document.createElement('li');
    item.textContent = note.title;          // textContent, not innerHTML: input is not interpreted as HTML
    const remove = document.createElement('button');
    remove.textContent = 'Delete';
    remove.addEventListener('click', async () => {
      await window.notes.remove(note.id);
      render();
    });
    item.append(' ', remove);
    return item;
  }));
}

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  error.textContent = '';
  try {
    await window.notes.add({
      title: document.querySelector('#title').value,
      body: document.querySelector('#body').value,
    });
    form.reset();
    render();
  } catch (err) {
    error.textContent = err.message;
  }
});

render();
RunBASH
npm start

Tip

  • Checking typeof require and typeof process on the page gives "undefined" for both. That is the normal state: the page cannot reach Node.js.
  • With React or Vue, build the page with a bundler such as Vite and load the dev server address during development and the built files after packaging. The Vite template of Electron Forge sets this up for you.

Security settings

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 forbidden
contextBridge.exposeInMainWorld('ipc', ipcRenderer);
ItemSettingWhy
contextIsolationtrue (default)Separates the global scope of preload from the page so page code cannot touch Electron internals.
sandboxtrue (default)Keeps the renderer process from accessing operating system resources directly.
nodeIntegrationfalse (default)Keeps the page from using Node.js modules through require.
webSecuritytrue (default)Keeps the same-origin policy. Do not turn it off for development convenience.
Content-Security-Policyscript-src 'self'Runs only scripts bundled with the app and blocks inline and external scripts.
IPC input validationCheck type Β· length Β· range in mainValues sent by the page can be tampered with.
IPC sender checkCheck event.senderFrame.urlRejects requests from frames that are not the app's page.
Navigation Β· new window limitswill-navigate, setWindowOpenHandlerKeeps the app window from turning into an arbitrary website.
shell.openExternalAllow https addresses onlyPassing values through unchecked can run arbitrary programs or files.
Remote contenthttps only, local files only where possibleDo not attach preload to a window that loads an external page.

Tip

  • Older articles often show examples that use fs straight from the page with nodeIntegration: true. That approach is no longer recommended; do not follow it.
  • Upgrading Electron is itself a security measure. Chromium vulnerability patches arrive through Electron updates.
  • For an app you distribute, also consider turning off debugging features such as ELECTRON_RUN_AS_NODE with @electron/fuses.

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 saving
export 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 systemuserData location
Windows%APPDATA%\AppName
macOS~/Library/Application Support/AppName
Linux~/.config/AppName

Tip

  • A JSON file is enough for a few settings and a small list. When data grows or you need search and sorting, look at SQLite.
  • Do not store secrets such as login tokens in a plain-text file; encrypt them with safeStorage. It uses the operating system's key store (Windows DPAPI, the macOS Keychain, and so on).
  • You can also store data in the page's localStorage, but it is hard to read from main and has a size limit. App data is easier to handle when main manages it as files.

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.

Application menuJAVASCRIPT
import { Menu } from 'electron';

const menu = Menu.buildFromTemplate([
  {
    label: 'File',
    submenu: [
      { label: 'New Memo', accelerator: 'CmdOrCtrl+N', click: () => win.webContents.send('menu:new-note') },
      { type: 'separator' },
      { role: 'quit', label: 'Quit' },
    ],
  },
  {
    label: 'Edit',
    submenu: [{ role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }],
  },
  {
    label: 'View',
    submenu: [{ role: 'reload' }, { role: 'toggleDevTools' }],
  },
]);
Menu.setApplicationMenu(menu);
Open file dialogJAVASCRIPT
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 disappear

function 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();

Tip

  • CmdOrCtrl in an accelerator means Command on macOS and Ctrl on Windows and Linux.
  • On macOS the first menu in the menu bar is always the app name. If you support macOS, add the app menu first when process.platform === "darwin".
  • Keep Tray and BrowserWindow objects in module-scope variables. When the reference is gone, the window or icon closes without warning.

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 app
if (!app.isPackaged) {
  win.webContents.openDevTools({ mode: 'detach' });
}
TargetHowWhat you see
Renderer (page)Ctrl+Shift+I in the window (Cmd+Option+I on macOS), or win.webContents.openDevTools()Console, elements, network, CSP violation messages
Main processThe terminal where you ran npm startconsole.log output, errors and stacks from IPC handlers
Main process (breakpoints)electron --inspect=5858 . then connect from chrome://inspectBreakpoints, variables, call stack
PreloadThe renderer's DevToolsconsole.log output and errors from preload appear in the page's Console.

Tip

  • If window.notes is undefined on the page, preload did not run. First check the DevTools Console for a preload error and check that the preload path is right.
  • The original stack of an error from an IPC handler appears in the terminal (the main process output), not on the page.

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/test
import { 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();
});

Tip

  • So that E2E tests do not use the real userData folder, have the app read a test environment variable and call app.setPath("userData", tempFolder).
  • Code that is hard to test usually has Electron APIs and logic mixed in one function. Separating the logic is the most effective fix.

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.

InstallBASH
npm install --save-dev @electron-forge/cli @electron-forge/maker-zip
forge.config.jsJAVASCRIPT
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' },
  ],
};
package.json β€” scriptsJSON
{
  "scripts": {
    "start": "electron-forge start",
    "package": "electron-forge package",
    "make": "electron-forge make",
    "test": "node --test"
  }
}
BuildBASH
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
MakerOutputTarget
@electron-forge/maker-zipZip archiveEvery operating system (simplest)
@electron-forge/maker-squirrelInstaller (Setup.exe)Windows
@electron-forge/maker-dmg.dmg disk imagemacOS
@electron-forge/maker-deb.deb packageDebian Β· Ubuntu
@electron-forge/maker-rpm.rpm packageFedora Β· RHEL

Tip

  • Only packages in dependencies are included in the app. If a package needed at runtime is in devDependencies, the packaged app fails with "Cannot find module".
  • In a packaged app the source is inside app.asar, so paths differ from ordinary file paths. Find files bundled with the app relative to import.meta.dirname and keep user data in app.getPath("userData").
  • The output of this example was about 370MB as a folder and about 150MB as a zip on Windows. Most of it is Chromium and Node.js.
  • Commands and configuration formats of Electron Forge change between versions. This guide is based on Forge 8; if you are starting a project, look at the templates in the official docs (Vite, TypeScript included) first.

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 app
if (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 systemWhat you needWithout signing
WindowsA code signing certificateA SmartScreen warning appears and the user has to allow the app to run.
macOSAn Apple Developer account, signing and notarizationGatekeeper blocks the app. Auto-update does not work either.
LinuxNot requiredFollow the package repository policy of the distribution.

Tip

  • For an open source app that publishes releases on a public GitHub repository, the update-electron-app module adds auto-update without an update server.
  • Do not force a restart while the user is working; download the update and ask whether to restart.
  • Update files must be delivered over https and only signed ones installed. If the update path is compromised, malicious code is distributed to every user.
  • Bump version in package.json for every release. That version decides whether an update is needed.

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.

SymptomCauseFix
Slow startupAll 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 startupThe window is shown before the page is drawn.Create it with show: false and show it on ready-to-show.
The whole window freezesLong synchronous work runs in the main process.Switch to async functions and move CPU work to a utilityProcess or a Worker.
Interaction stuttersThe 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 growingReferences 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 slowLarge data is exchanged often.Send only what is needed and handle large files by path or as streams.

Tip

  • When the main process stalls, input and IPC for every window stall with it. Do not use sync functions such as readFileSync in main while handling requests. The reason is the same as the Node.js event loop.
  • One window is one renderer process. The more windows you create, the more memory you use.

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 Β· errorCauseFix
npm start opens no window and behaves like Node.jsThe 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 undefinedPreload 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 readyA window was created before the ready event.Move the window creation code after app.whenReady().
An object could not be clonedA 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 screenThe 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 scriptCSP blocked an inline script.Move the script to a separate .js file. Do not loosen the CSP.
Cannot find module in the packaged appA 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 packagingA 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").

Tip

To narrow a problem down quickly, check typeof window.notes on the page (is it a preload problem?) and then whether the terminal shows an error (is it a main problem?).

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 doGuide to read next
Understand Node.js, the base of the main process, in depthNode.js guide
Make the IPC contract safe with typesTypeScript guide
Build the page from componentsReact guide Β· Vue guide
Strengthen the language basicsJavaScript guide
Automate builds per operating systemCI/CD guide
Work with local data in SQLCommon SQL guide

Tip

Try adding "edit memo" and "export as a text file" to this guide's memo app. You will build an extra IPC channel, input validation, and a save dialog once each yourself.

← Previous guideNuxt.js