← Back to Writing
Electron Desktop gRPC

Shipping a Desktop App That Drives a Fleet of Services: Electron + gRPC

September 28, 2021 · 6 min read

Electron gives you a window and a Node runtime. That is the easy 10%, and it is the part every tutorial covers. The hard 90% of a serious desktop app is everything around it: shipping signed, notarized builds for macOS and Windows that users can install without scary warnings, auto-updating them in the field, spawning and supervising worker processes for parallel work, talking to backend services over a real protocol, and not having your whole product trivially copied out of the application bundle. None of that is what people mean when they say "I will just use Electron," and all of it is what separates a weekend demo from something you can ship and support.

flowchart LR M["Main process<br/>orchestrator · updater · licensing"]:::accent --> R["Renderer (UI)"] M --> W["Worker processes (parallel tasks)"] M --> G["gRPC client"] --> S["Backend services"] U["Update server"]:::ghost -.-> M

The process model is the architecture

Electron's split between the privileged main process and the sandboxed renderer is not a formality, it is your security and stability boundary, and you lean into it. The main process is the orchestrator: it owns the auto-updater, the license checks, and the lifecycle of the worker processes it spawns for concurrent jobs. The renderer is just UI, and you keep it that way, with context isolation on and Node integration off, so a bug or a piece of hostile content in the UI layer cannot reach into the operating system. Communication between them goes over a narrow, explicit IPC channel rather than handing the renderer the keys to everything, because the renderer is the part most exposed to the outside world.

Workers, supervised

For real parallel work you spawn separate processes rather than blocking the UI thread or cramming everything into one event loop. That turns into a supervision problem you actually have to solve, not just a spawn call. You monitor health, restart the ones that die, cap how many run at once, and apply back-pressure so a runaway worker does not exhaust the machine and take the whole app down with it. The main process is effectively a small process manager, and treating it like one, with real lifecycle handling, is the difference between an app that runs for an hour in a demo and one that runs for a week on a stranger's laptop.

The gRPC boundary

A gRPC client connects the app to backend services for anything that must be trusted, shared across users, or kept off the client entirely. gRPC earns its place here for two concrete reasons. The first is typed contracts: the client and server cannot silently drift, which matters a great deal when the desktop app and the backend ship on completely different cadences and an old client will still be talking to a new server months later. The second is first-class streaming, which you want for progress, live status, and long-running operations. It is also a clean trust boundary: the desktop app is untrusted, and the service is where you decide what it is actually allowed to do.

Distribution is its own project

This is the part teams consistently underestimate, and it has nothing to do with your features. Code signing and notarization are non-negotiable on modern macOS; skip them and users get a flat "this app is damaged" wall with no hint that the fix is a signature. Windows wants a Squirrel installer and its own code signing. You need a release channel the auto-updater can pull from, and ideally delta updates so you are not making every user re-download hundreds of megabytes for a small fix. The app checks for updates on launch and applies them quietly in the background. None of this ships a single feature, and all of it is the difference between software people install once and software they keep.

flowchart LR CI["CI build"]:::ghost --> SN["Sign + notarize (macOS)<br/>Squirrel package (Windows)"] SN --> US["Release channel / update server"] US -.->|app checks on launch| APP["Installed app"]:::accent APP --> DL["Download + apply update"]

Protecting what matters

People reach for code obfuscation to protect a desktop bundle, and it is worth being clear-eyed about what that buys you: it slows down a casual look, and it does nothing against a determined reverse engineer, because everything shipped to the user's machine is, in the end, readable by the user's machine. So the rule is simple. Anything that must stay secret, a real credential, proprietary server-side logic, the thing your business actually depends on, lives behind the gRPC boundary on infrastructure you control, never in the app. The client holds the UI and the workflow, not the crown jewels.

Offline and local state

One of the strongest reasons to be on the desktop at all is that the app keeps working when the network does not, and that is a feature you have to design rather than inherit. Local state, the user's data, queued actions, cached results, lives in a real local store, and the app treats the backend as something it syncs with rather than something it depends on for every interaction. That means designing for conflict: when the machine comes back online, the actions it queued while offline have to reconcile with whatever changed on the server in the meantime, and "last write wins" is usually too blunt for anything that matters. Getting this right is most of what makes a desktop app feel solid rather than like a website in a frame, and getting it wrong is how you lose a user's work the first time their connection drops.

Updates are a trust surface

An auto-updater is, by definition, a process that downloads code and runs it on the user's machine, which makes it one of the most security-sensitive parts of the whole product. If an attacker can impersonate your update server or tamper with a release in transit, they own every installation, so updates are signed and verified before they are applied, and the app refuses anything whose signature does not check out. The same discipline that protects your users also protects you from shipping a broken release to everyone at once: you stage rollouts, you keep the ability to halt a bad update before it reaches the whole fleet, and you can push a fix quickly when something does slip through. The update channel is power, and you treat it with the caution that power deserves.

When not to reach for it

Electron is memory-hungry, because every app ships an entire browser engine, so you stay honest about footprint and prefer worker processes over a single bloated renderer. More fundamentally, Electron is a deliberate trade, not a default. You reach for it when you genuinely need local system access, offline capability, or to coordinate processes on the user's own machine. If you do not need any of those, a web app is lighter to build, lighter to ship, and lighter for the user to run, and pretending otherwise just saddles everyone with a browser running inside a browser for no reason.

Enjoyed this? Let me know

0claps