July 29, 2026
Electron App Revolution: Enhance with Automatic Updates
Shipping a desktop app is one thing. Keeping it updated across thousands of machines — without asking users to manually download and…

By Sahil Khurana
5 min read
Shipping a desktop app is one thing. Keeping it updated across thousands of machines — without asking users to manually download and reinstall every release — is a completely different problem.
Most Electron apps start without thinking about this. A v1.0 goes out, a few bugs get fixed, v1.1 is ready, and then someone realizes half the users are still running v1.0 because they never noticed there was a new version. Auto-update solves this. And Electron's ecosystem makes it genuinely straightforward, once you understand what the pieces are and how they connect.
What Electron Actually Is
Electron bundles Chromium (the rendering engine behind Chrome) with Node.js into a single runtime that can run on Windows, macOS, and Linux. The result: you write your app in HTML, CSS, and JavaScript — frontend tech most teams already know — and ship it as a native desktop executable.
Apps built this way power tools you probably use every day. VS Code, Slack, Figma's desktop client, 1Password — all Electron.
File Structure: The Three Files That Matter
Every Electron app starts from three core files:
my-electron-app/
├── index.html ← the UI — what renders in the app window
├── main.js ← the main process — Node.js APIs, window management, app lifecycle
└── preload.js ← the bridge — safely exposes Node APIs to the renderermy-electron-app/
├── index.html ← the UI — what renders in the app window
├── main.js ← the main process — Node.js APIs, window management, app lifecycle
└── preload.js ← the bridge — safely exposes Node APIs to the rendererindex.html is your application's default view. It's just HTML — no special Electron syntax required.
main.js is where Electron's main process lives. This is a Node.js environment: it creates the browser window, handles app lifecycle events (ready, close, quit), and has access to native OS capabilities.
preload.js runs in a privileged context between the main process and the renderer. It's the right place to expose specific Node.js APIs to your frontend code without opening the entire Node environment to untrusted content.
Setting Up from Scratch
# Create and initialize the project
mkdir my-electron-app && cd my-electron-app
npm init -y
# Install Electron
npm install --save-dev electron# Create and initialize the project
mkdir my-electron-app && cd my-electron-app
npm init -y
# Install Electron
npm install --save-dev electronAdd a start script to package.json:
{
"scripts": {
"start": "electron ."
}
}{
"scripts": {
"start": "electron ."
}
}A minimal main.js:
const { app, BrowserWindow } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
win.loadFile('index.html');
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});const { app, BrowserWindow } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
win.loadFile('index.html');
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});Run npm start and the app window opens. That's the full foundation.
How Electron Processes Work
Electron splits execution between two process types, and understanding this distinction is important before adding auto-update.
Main Process — runs in Node.js. Creates and manages windows, handles OS-level events, accesses native APIs (file system, notifications, system tray, etc.). There's exactly one main process per app instance.
Renderer Process — one per window, runs in a Chromium context. This is where your HTML and frontend JavaScript execute. It has access to the DOM but is intentionally sandboxed from Node.js APIs by default.
IPC (Inter-Process Communication) — the bridge between them. ipcMain and ipcRenderer let the main and renderer processes send messages back and forth. This is how you'd notify the UI when an update is available, or let a button press in the renderer trigger an update install.
// In main.js — listen for a message from the renderer
const { ipcMain } = require('electron');
ipcMain.on('check-for-updates', (event) => {
autoUpdater.checkForUpdates();
});
// In renderer.js — send a message to the main process
const { ipcRenderer } = require('electron');
document.getElementById('update-btn').addEventListener('click', () => {
ipcRenderer.send('check-for-updates');
});// In main.js — listen for a message from the renderer
const { ipcMain } = require('electron');
ipcMain.on('check-for-updates', (event) => {
autoUpdater.checkForUpdates();
});
// In renderer.js — send a message to the main process
const { ipcRenderer } = require('electron');
document.getElementById('update-btn').addEventListener('click', () => {
ipcRenderer.send('check-for-updates');
});Packaging Your App
Before you can distribute or auto-update anything, the app needs to be packaged into platform-specific executables. Two main tools handle this:
electron-forge — the officially recommended tool, tightly integrated with Electron's release process. Handles building, packaging, and publishing in one workflow.
electron-builder — more configurable, slightly more complex to set up, but gives finer control over output formats and signing configuration. The choice most teams with advanced distribution needs reach for.
For most projects starting fresh, electron-forge is the simpler starting point.
Code Signing: Required, Not Optional
Code signing is the process of certifying that a binary was built by a known, verified publisher. Both Windows and macOS require it — on macOS, unsigned apps show a scary "unidentified developer" warning and can be blocked entirely; on Windows, SmartScreen will flag unsigned installers.
macOS — signing happens at packaging time. You need an Apple Developer certificate, set in your build configuration. Notarization (submitting the app to Apple for security scanning) is also required for Gatekeeper compatibility.
Windows — the distributable installer is signed, not the raw executable. You need a code signing certificate from a certificate authority (DigiCert, Sectigo, etc.) and set it in your build config.
Both electron-forge and electron-builder have native support for reading signing credentials from environment variables, which keeps secrets out of your codebase.
Auto-Update: The Full Setup
Electron's auto-update feature is built in and free. Four conditions have to be met:
- The app must run on macOS or Windows (Linux auto-update isn't supported natively)
- The app needs a GitHub repository — public works out of the box; private repos need a
GH_TOKENenvironment variable - Builds must be published to GitHub Releases
- Builds must be code signed
Once those are in place, here's how the update flow works with each tool.
With electron-forge
Install the GitHub publisher:
npm install --save-dev @electron-forge/publisher-githubnpm install --save-dev @electron-forge/publisher-githubUpdate forge.config.js:
module.exports = {
publishers: [
{
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'your-github-username',
name: 'your-repo-name',
},
prerelease: false,
},
},
],
};module.exports = {
publishers: [
{
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'your-github-username',
name: 'your-repo-name',
},
prerelease: false,
},
},
],
};Add the publish script to package.json:
{
"scripts": {
"publish": "electron-forge publish"
}
}{
"scripts": {
"publish": "electron-forge publish"
}
}Run npm run publish and electron-forge builds, packages, and pushes a new GitHub Release.
With electron-builder
Install the dependency:
npm install --save-dev electron-buildernpm install --save-dev electron-builderConfigure package.json:
{
"build": {
"appId": "com.yourcompany.yourapp",
"publish": {
"provider": "github",
"owner": "your-github-username",
"repo": "your-repo-name"
}
},
"scripts": {
"dist": "electron-builder",
"release": "electron-builder --publish always"
}
}{
"build": {
"appId": "com.yourcompany.yourapp",
"publish": {
"provider": "github",
"owner": "your-github-username",
"repo": "your-repo-name"
}
},
"scripts": {
"dist": "electron-builder",
"release": "electron-builder --publish always"
}
}Handling Updates in the App
Regardless of which publisher you use, the main process needs to respond to update events:
const { autoUpdater } = require('electron-updater');
const { ipcMain } = require('electron');
// Check for updates when the app starts
app.whenReady().then(() => {
autoUpdater.checkForUpdatesAndNotify();
});
// Notify the renderer when an update is downloaded
autoUpdater.on('update-downloaded', (info) => {
mainWindow.webContents.send('update-available', info.version);
});
// Install the update when the user confirms
ipcMain.on('install-update', () => {
autoUpdater.quitAndInstall();
});const { autoUpdater } = require('electron-updater');
const { ipcMain } = require('electron');
// Check for updates when the app starts
app.whenReady().then(() => {
autoUpdater.checkForUpdatesAndNotify();
});
// Notify the renderer when an update is downloaded
autoUpdater.on('update-downloaded', (info) => {
mainWindow.webContents.send('update-available', info.version);
});
// Install the update when the user confirms
ipcMain.on('install-update', () => {
autoUpdater.quitAndInstall();
});On the renderer side:
const { ipcRenderer } = require('electron');
ipcRenderer.on('update-available', (event, version) => {
// Show a notification or prompt in the UI
const userConfirmed = confirm(`Version ${version} is ready. Install now?`);
if (userConfirmed) {
ipcRenderer.send('install-update');
}
});const { ipcRenderer } = require('electron');
ipcRenderer.on('update-available', (event, version) => {
// Show a notification or prompt in the UI
const userConfirmed = confirm(`Version ${version} is ready. Install now?`);
if (userConfirmed) {
ipcRenderer.send('install-update');
}
});This gives users a choice rather than forcing a restart — which matters for apps where people might be mid-task.
Automating Releases with GitHub Actions
Running the publish script manually works, but most teams automate this through CI. A GitHub Actions workflow that builds and publishes on every tagged commit:
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- name: Publish
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: npm run releasename: Release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm install
- name: Publish
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
run: npm run releasePush a tag (git tag v1.2.0 && git push --tags) and the workflow fires, builds for both macOS and Windows, signs the binaries, and creates a GitHub Release. The auto-updater in any running instance of your app picks it up on the next check.
What to Watch Out For
Delta updates — by default, electron-updater downloads the full installer on each update. For large apps this is slow. Both electron-builder and the underlying Squirrel (macOS) and NSIS (Windows) installers support differential/delta updates, but it requires additional configuration.
Update channels — you might want a beta channel for testing before pushing to all users. electron-updater supports this via the channel configuration option and GitHub pre-releases.
Silent vs. prompted updates — autoUpdater.quitAndInstall() is immediate. Always give the user a heads-up and a way to postpone if the update requires a restart. Nobody likes an app closing mid-work.
macOS notarization — since macOS Catalina, apps need to be notarized in addition to signed. electron-builder and electron-forge both support this, but it requires additional Apple Developer account setup and adds a few minutes to your CI build time.
Building Desktop Apps with Electron?
At Innostax, we build with both — the choice comes from what the project actually needs, not what we happen to prefer. Whether that's electron-forge for a straightforward setup or electron-builder for more granular control, packaging strategy follows what the distribution requirements actually demand.
If you're figuring out the right stack for something you're building, innostax.com/contact is the right place to start that conversation.
Originally published on the Innostax Engineering Blog.