A few months ago I built an internal desktop tool for my company using Electron. It runs on machines across every branch office — different cities, different networks, different levels of IT literacy on the other end.
Last week I needed to ship a fix. Not a "whenever people get around to it" fix — a mass update, same day, everywhere.
Here's the part that made that possible: I never had to visit a single branch, email a setup file, or ask anyone to click "install." I opened my terminal, ran two commands, and walked away. By the next morning, every machine running the app had already updated itself.
That wasn't luck. It was because a few months earlier — before I needed it — I had wired up a proper auto-update system using electron-updater. This post breaks down exactly how that system works, end to end, and how it turned what should have been a logistical nightmare into a non-event. Hopefully it saves someone else the trial and error.
The problem with internal desktop software
If you've ever shipped an internal Electron app to a non-technical audience spread across multiple physical locations, you already know the pain:
- You can't rely on people to download and run an installer.
- You can't assume IT support is on-site everywhere.
- You can't push through a public app store — it's internal software.
- "Just remote into each machine" does not scale past two or three branches.
The moment your user base is decentralized, manual distribution becomes your biggest bottleneck — not writing the fix itself.
The fix for the bottleneck isn't a better installer. It's removing the installer from the loop entirely.
The architecture: four layers, one goal
The system I built is based on electron-updater (from the electron-builder ecosystem) pointed at a generic HTTP provider — meaning updates are hosted on a plain web server I control via SFTP, with no dependency on GitHub Releases or a cloud storage vendor. That mattered a lot for internal company software that shouldn't be sitting in a public repo.
There are four distinct layers doing the work:
| Layer | Responsibility |
|---|---|
| Build & publish | Compile the app, upload the installer and metadata to the server |
| Main process | On launch, silently check for a newer version and download it |
| Preload bridge | Safely relay update events from the sandboxed main process to the UI |
| Renderer UI | Show the user what's happening, without letting them stop it |
Let's walk through each one — and where the "mass update to every branch" moment actually happens.
Step 1: Building and publishing a release
Everything starts with a single script:
npm run publish
# → npm run build && npm run upload
Compiling the app
nextron build compiles both the Next.js renderer and the Electron main process into a dist/ folder. electron-builder handles packaging based on electron-builder.yml:
publish:
- provider: generic # plain HTTP server, no GitHub / S3
url: http://<server>/updates/
channel: latest
useMultipleRangeRequest: false
This one config block is the reason I own the entire update pipeline. There's no third-party service that could rate-limit me, gate access behind auth I don't control, or go down at the wrong moment. It's just a server I already trust.
Every build also auto-generates a latest.yml file — the single source of truth for the update system. It contains the new version number, the installer filename, a SHA-512 hash for integrity verification, and the release date. This file is what every branch's app will silently check against.
Uploading with resume logic
The upload script (scripts/upload-update.mjs) pushes everything to the server over SFTP, and it's smarter than a blind file copy:
- For each artifact, it sends a
HEADrequest to check what's already on the server. - If the remote file matches the local size exactly, it's skipped — already complete.
- If it's a partial upload, it resumes instead of restarting.
- YAML metadata files are always overwritten, even if the size looks identical — because stale metadata is the one thing that would silently break every client at once.
- Missing files get a full upload.
Given that some of our branches sit on genuinely flaky connections, the SSH session is also tuned for resilience — a keepalive probe every 15 seconds, tolerating up to 10 missed probes (roughly 150 seconds) before giving up. That's the kind of detail you don't think about until a publish fails halfway through at 6 PM and you're debugging a timeout instead of going home.
This is the entire "developer side" of a mass update. I run npm run publish. That's it. Everything past this point happens on machines I will never log into.
Step 2: The main process — where the magic actually happens
This is the layer that turns a file sitting on a server into an update running silently on a machine in a branch office 400 kilometers away.
On every app launch, in production builds only, the following runs:
autoUpdater.logger = log
autoUpdater.logger.transports.file.level = 'info'
autoUpdater.autoDownload = true // no prompt — just download
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.checkForUpdates().catch((err) => {
log.error('Initial update check failed', err)
})
autoDownload: true is the key decision here. There's no "a new version is available, click to download" dialog for the user to ignore. electron-updater fetches latest.yml, compares versions, and if there's a newer one, it starts downloading immediately.
Six events are wired up, each forwarded to the renderer over IPC so the UI can react:
| Event | What it means |
|---|---|
checking-for-update |
Started polling the server |
update-available |
A newer version exists — download begins |
update-not-available |
Already current |
download-progress |
Live bytes/percent/speed |
update-downloaded |
Installer verified and ready |
error |
Something went wrong — logged, UI recovers gracefully |
Once the download finishes and the hash is verified:
autoUpdater.on('update-downloaded', (info) => {
mainWindow.webContents.send('update-downloaded', { version: info.version })
setTimeout(() => {
autoUpdater.quitAndInstall(true, true)
}, 3000)
})
Three seconds after the "installing" message renders, quitAndInstall(true, true) fires — silently, no confirmation dialog, force-closing all windows and running the installer immediately. The app comes back up seconds later on the new version.
No one at any branch had to know an update even shipped that day. It just... happened, in the background, between one launch and the next.
Step 3: The preload bridge
Electron's sandboxed renderer can't touch Node or Electron APIs directly, and it shouldn't — that's a security boundary you don't want to punch a hole through carelessly. The preload script exposes a narrow, controlled interface:
contextBridge.exposeInMainWorld('ipc', {
on(channel, callback) {
const subscription = (_event, ...args) => callback(...args)
ipcRenderer.on(channel, subscription)
return () => ipcRenderer.removeListener(channel, subscription)
},
// ...send, invoke
})
The returned cleanup function matters more than it looks — without it, every hot reload or component remount during development leaks a listener, and eventually you're debugging duplicate update dialogs firing three times for no reason.
Step 4: The renderer — giving users just enough visibility
Silent updates shouldn't mean invisible updates. If someone's mid-task and the app is about to restart itself, they deserve to see that coming.
Global listeners are registered once in _app.tsx, driving three pieces of state:
update-available → modal appears, "downloading" stage, progress starts at 0
download-progress → progress bar updates live
update-error → modal quietly disappears, no user-facing failure noise
update-downloaded → modal switches to "installing" stage
(3s later, the app restarts itself)
The modal itself is a fixed full-screen overlay with a backdrop blur — genuinely hard to miss, but not alarming. The downloading stage shows a live progress bar, transferred/total bytes, download speed, and an ETA calculated from remaining bytes divided by bytes-per-second. The installing stage swaps to a green indicator, a full progress bar, and a plain warning not to close the window because it's about to close itself anyway.
That's really the whole design philosophy: give people visibility, take away the decision. For internal software running business-critical workflows across branches, "click here to update" is a support ticket generator. Silent-but-visible is the sweet spot.
The day it actually mattered
Here's the payoff. A bug surfaced that needed fixing across the board — not "roll it out gradually," but everywhere, same day. In the old world, that would have meant:
- Packaging a new installer
- Emailing it to whoever manages each branch's machines
- Hoping someone actually ran it before end of day
- Following up for a week with the branches that didn't
Instead, this is what actually happened:
Developer:
npm run build → new version compiled, latest.yml regenerated with new hash
npm run upload → SFTP pushes installer + metadata to the server
Every branch, independently, on next app launch:
checkForUpdates() → sees new version in latest.yml
update-available → download starts automatically, no click needed
download-progress → user sees a clean progress bar
update-downloaded → app quits and reinstalls itself
App relaunches → running the fixed version
I didn't touch a single machine. I didn't send a single file over email or Slack. Every branch pulled the fix on its own schedule, the moment someone opened the app, with zero coordination required from me or from them.
That's the actual return on investment of building the update pipeline before you need it under pressure. The infrastructure work happens once, calmly, months in advance. The payoff shows up exactly when you least have time to improvise.
Key takeaways for your own Electron app
If you're building internal or B2B desktop software with electron-updater, a few lessons from this stood out:
- Use the generic HTTP provider if you need full control. Not every internal tool belongs on GitHub Releases or a vendor's CDN — owning the server means owning the rollout.
-
Always overwrite metadata files on upload. A stale
latest.ymlwith a fresh installer is a worse failure mode than no update at all — clients will silently think they're current when they're not. - Build resume logic into your upload step. Flaky connections are a fact of life for distributed offices; don't let a dropped SFTP session mean a corrupted release.
- Silent auto-download, visible progress. Don't ask non-technical users to make an update decision — but don't hide the fact that the app is about to restart itself either.
-
Verify with a hash, always. The SHA-512 check in
latest.ymlis what makesautoDownload: truesafe enough to trust across machines you'll never personally inspect.
Closing thought
The best infrastructure work is invisible right up until the one day it saves you. Building the auto-update pipeline felt like a "nice to have" the day I shipped it. Months later, on the day I needed to patch every branch in the company at once, it was the only reason that day didn't turn into a week.
If your desktop app doesn't have this yet — and it's going to live on more than one machine you don't control — this is the piece to build before you need it, not after.
Stack used: electron-updater (^6.8.3), electron-builder (^26.8.1), electron-log (^5.4.3), Next.js via nextron, deployed to a generic HTTP server over SFTP.
Have you dealt with rolling out updates across machines you don't control? Curious whether others went the generic-provider route or leaned on something like S3/GitHub Releases instead — and how you handled staged rollouts if the update ever needed to be pulled back. Drop your approach below 👇
Top comments (0)