DEV Community

Cover image for How I Pushed a Critical Update to Every Company Branch Without Touching a Single Machine (Electron Auto-Update Deep Dive)
Vinniharu
Vinniharu

Posted on

How I Pushed a Critical Update to Every Company Branch Without Touching a Single Machine (Electron Auto-Update Deep Dive)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. For each artifact, it sends a HEAD request to check what's already on the server.
  2. If the remote file matches the local size exactly, it's skipped — already complete.
  3. If it's a partial upload, it resumes instead of restarting.
  4. 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.
  5. 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)
})
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.yml with 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.yml is what makes autoDownload: true safe 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)