The first time I shipped an Electron app, it phoned home for an update, downloaded the new version, and then refused to install it because the whole thing wasn’t signed. On Windows it threw a SmartScreen wall. On macOS it just sat there, quietly doing nothing, while I stared at logs wondering why the auto-updater I’d carefully wired up was acting like it had never heard of my app. Turned out the code was fine. The packaging was the problem. It’s always the packaging.
I built a desktop notes app on Electron 26 with React and Vite. Real thing, real users, auto-updates and all. And I went in with the usual internet-flavored dread - Electron ships a whole Chromium, it eats RAM, it’s bloated, real devs use native. Some of that’s true. Most of it doesn’t matter once you respect how the thing actually works. So here’s what I’d tell myself before starting.
Two processes, and pretending otherwise will hurt you
Electron has a main process (Node, owns the OS-level stuff - windows, menus, files, the app lifecycle) and one or more renderer processes (Chromium, your actual UI). That’s it. That’s the whole mental model, and almost every mess I made early on came from forgetting which side I was on.
The renderer is a web page. It should think it’s a web page. It should not be reaching into the filesystem or spawning child processes. The main process is your backend - the only place with real power. Your React app talks to it the way a frontend talks to an API, except the “network” is IPC.
Once that clicked, everything got calmer. I stopped trying to import fs in a component and cursing when Vite screamed at me.
contextBridge, and please don’t use remote
If you read one old Stack Overflow answer, it’ll tell you to flip on nodeIntegration and use the remote module so the renderer can just call Node directly. Do not. That was the easy path years ago and it’s a genuine security hole - you’re handing your web page (which might load who-knows-what) full Node access.
The right shape: nodeIntegration: false, contextIsolation: true, and a preload script that exposes a tiny, hand-picked API through contextBridge.
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('notesApi', {
save: (note) => ipcRenderer.invoke('note:save', note),
onSynced: (cb) => ipcRenderer.on('note:synced', (_e, data) => cb(data)),
})
The non-obvious bit: notice I never expose ipcRenderer itself. If you do exposeInMainWorld('ipc', ipcRenderer) you’ve basically undone the whole isolation - now the renderer can invoke any channel and listen to anything. Expose named functions that call specific channels. Nothing else. Think of it as an allowlist, because that’s exactly what it is.
On the main side it’s just as boring, which is the point:
ipcMain.handle('note:save', async (_event, note) => {
const saved = await writeNoteToDisk(note)
return saved.id
})
invoke/handle gives you promises, so the renderer awaits like it’s calling fetch. Clean. The old send/on fire-and-forget pattern still exists and I use it for one-directional stuff (progress events, “hey a sync finished”), but for request-response, invoke every time.
contextIsolation breaks things and that’s fine
Turning contextIsolation on will break some libraries that assume they can scribble on the global scope shared between preload and page. When I hit that, my instinct was to turn isolation off to “make it work.” Wrong move. Ninety percent of the time the fix is: move that logic into the main process where it belongs, and pass results across IPC. The library wanting global Node access was the smell, not the solution.
Vite is great, until the main process reminds you it’s Node
Vite for the renderer is a joy - HMR, fast builds, all the modern React stuff you already know. But your main and preload scripts aren’t browser code. They’re Node, and they need bundling with a completely different target. Getting Vite to build three things (renderer for the browser, main and preload for Node) with the right externals took me an embarrassingly long afternoon.
The thing that bit me hardest: paths. In dev, the renderer loads from a Vite dev server URL. In production, it loads a file off disk from inside the packaged app. If you hardcode one, the other breaks, and you won’t notice until you package.
if (app.isPackaged) {
win.loadFile(path.join(__dirname, '../renderer/index.html'))
} else {
win.loadURL('http://localhost:5173')
}
app.isPackaged is your friend. Branch on it early, everywhere.
Packaging and signing: the actual time sink
Here’s my real opinion after shipping this: writing the app was maybe 30% of the effort. Packaging and code signing was the rest, and nobody warns you loudly enough.
I used electron-builder and it’s genuinely good - one config block, cross-platform installers, sensible defaults. But signing is a special kind of hell that has nothing to do with your code:
- macOS wants a Developer ID cert, and then Apple wants you to notarize - upload the whole app to their servers, wait, staple a ticket to it. Miss notarization and Gatekeeper tells your users the app is damaged. It isn’t. It’s just unnotarized, which macOS treats as basically the same crime.
- Windows wants a code-signing cert too, and without one, SmartScreen greets every user with a scary warning that murders your install rate.
Budget days for this, not hours. The first successful notarized build felt better than shipping any actual feature. And CI is where you want this living - signing certs and notarization on your laptop is fine once, but you’ll hate yourself doing it manually on release number four.
Auto-update: lovely when it works, silent when it doesn’t
electron-updater pairs with electron-builder and, once signing is sorted, it’s almost too easy:
const { autoUpdater } = require('electron-updater')
app.on('ready', () => {
autoUpdater.checkForUpdatesAndNotify()
})
autoUpdater.on('update-downloaded', () => {
// prompt the user, then autoUpdater.quitAndInstall()
})
The trap: auto-update requires signed builds. That failed install I opened with? That was the updater downloading a perfectly good unsigned build and the OS refusing to run it. The updater wasn’t broken. My understanding was.
Also - and this is why I’ll fight for electron-log - the updater fails quietly. It won’t throw in your face. It’ll just decide there’s no update and move on. Without logs written to disk, you’re blind, especially on a user’s machine you’ll never see. Wire logging into the updater’s events on day one:
const log = require('electron-log')
autoUpdater.logger = log
log.transports.file.level = 'info'
When someone reports “update didn’t work,” those files are the difference between a two-minute fix and a week of guessing.
The RAM thing, honestly
Yeah, it’s a Chromium per app, plus a Node process. My notes app idled around 150-200MB. That’s not nothing. If you’re building a tiny menu-bar utility, that overhead is genuinely embarrassing and I’d reach for something native or Tauri instead.
But for an app with a real UI where I wanted to reuse React and ship the same code to three platforms in a week? The tradeoff paid for itself many times over. Users on modern machines didn’t blink. I stopped feeling guilty about it around the time I realized every other app on their desktop was doing the same thing.
So, do I hate it?
No. I went in expecting to. Electron gets dunked on constantly and some of it’s fair - it is heavy. But nearly all the pain I actually felt came from two places: fighting the process model instead of respecting it, and underestimating signing. Get the contextBridge boundary right, branch on app.isPackaged, log the updater, and set aside real time for certs - and it’s honestly a pleasant way to ship a cross-platform desktop app without learning three native UI toolkits.
Respect the two processes. That’s most of the game.