Turning a web app into a desktop app with Electron

The first Electron build takes about twenty minutes and works. A window opens, the web app loads, there is an icon in the Dock, and the whole thing feels finished. The parts that decide whether this was a good idea show up later: the first time the app has to be handed to somebody else, the first time a Chromium security release lands, and the first time a login flow bounces through a second domain inside a window with no address bar. Knowing those in advance is what separates a two hour job from a project.

The working minimum

Electron runs two kinds of process. A main process, which is Node.js and owns windows, menus, and anything that touches the operating system. And a renderer process per window, which is Chromium and displays the page. A wrapper for an existing web app is a main process file that creates one window and points it somewhere.

That somewhere comes in two forms, and the choice is the most consequential decision in the project.

Loading a remote URL keeps the app thin. The bundle holds the runtime and the setup file, and every deployment of the web app is live in the desktop app immediately, because the app is only a client. The release pipeline for the website does not change at all.

Loading bundled files from disk makes the app self contained and able to start without a network. In exchange it acquires a version number. Front end changes now require shipping a build to every installed copy, the API base URL has to become configurable, and requests that were same origin become cross origin, so the server has to permit them.

A web app that is already deployed and always online almost never needs the second form. Choosing it by default is the most common way a two hour job turns into a two week one.

What Electron gives that a tab does not

If the goal is only a separate window with its own icon, macOS and Chrome already do that without any code. Electron earns its place when the requirement includes something a browser deliberately refuses to hand a web page.

A native application menu, with real macOS keyboard shortcuts that do not collide with browser shortcuts, and menu items that call into the page.

A global keyboard shortcut that fires while a different application is focused, which is how a note taking or timer window gets summoned instantly.

A tray or menu bar item, and the ability to keep running with no window open.

A registered URL scheme, so links in mail or chat open the app directly, plus file type associations.

Direct filesystem access from the main process, without the browser's file picker, which is what watching a folder or writing to a fixed path requires.

Control over the window itself: fixed size, always on top, no title bar, kiosk mode, restoring the previous position on launch, and a Dock badge driven by the page.

Each of these is a real capability, and each one is also a reason to keep a list. If the honest list is empty, the rest of this article is a cost with nothing on the other side.

What it costs, and when

The costs are not in the build. They are in everything that recurs afterwards.

Every Electron application ships its own copy of Chromium and Node.js, which is why a bundle for a single page runs to hundreds of megabytes and why two Electron apps do not share an engine the way two tabs share a browser. On a machine already running a browser, each wrapped app is closer to a second browser than to a second tab.

Distribution requires signing with an Apple Developer ID and notarizing the result, or macOS refuses to launch it and reports an unidentified developer. That means membership in the Apple Developer Program at 99 US dollars per year, and a notarization step wired into every release, not just the first one.

Updates need somewhere to come from. A hosted feed with signed releases, or a manual download each time. This is the cost the remote URL approach avoids almost entirely, since the page updates when the server does.

The recurring item that gets forgotten is the runtime. Electron's release line follows Chromium's, with new major versions arriving on a rolling schedule and support concentrated on the most recent ones. When a browser vulnerability is disclosed, an installed browser patches itself within days. A bundled runtime patches itself never. It is fixed only when someone rebuilds against a newer Electron and ships that build to every user.

Build cross-platform desktop apps with JavaScript, HTML, and CSS. Source: electronjs.org

That promise is accurate about the build. It says nothing about the maintenance, which is where the ongoing effort actually lives.

The defaults worth checking

A wrapper that loads a remote page is executing network delivered code inside a process that can reach the operating system. Recent Electron versions default to safe settings, but tutorials copied from older posts frequently do not.

Node integration stays off in the renderer. Context isolation stays on. The sandbox stays enabled. Anything the page genuinely needs from the system goes through a preload script that exposes a small number of named functions over the context bridge, never a module loader and never the whole filesystem API.

Navigation needs an explicit policy. Left alone, any link opens inside the app window, where the user cannot see what is being loaded because there is no address bar. Handling new window requests and navigation attempts, keeping the intended origin inside and sending everything else to the default browser, is short and prevents the app from becoming an unlabelled browser.

Authentication is worth testing before rollout rather than after. Sign in flows that redirect through an identity provider can strand a user in a windowless frame with no way back. Leaving navigation controls available, or handling the redirect explicitly, is the difference between a support ticket and a non issue.

Packaging is a separate job from building

The gap between a window that opens on the developer's machine and a file another person can double click is wider than it looks, and it is where most of the schedule goes.

Packaging tools such as Electron Forge and electron-builder exist to close it. They take the source, pull in the runtime, produce an application bundle, and can drive the signing and notarization steps as part of one command. Setting that up once is the difference between a release taking five minutes and a release taking an afternoon of manual steps that get remembered incorrectly.

Several details only appear at this stage. Macs now come with two processor architectures in active use, so a build has to target Apple silicon, Intel, or both in a universal bundle, and testing on only one of them hides launch failures on the other. Notarization requires the hardened runtime, which in turn requires declaring entitlements for anything unusual the app does, and a missing entitlement produces a crash at launch rather than a helpful message. Icons need an icns file assembled from several sizes, because a single PNG dropped in will look soft in the Dock. Downloaded bundles carry a quarantine attribute until Gatekeeper clears them, so the first launch on a colleague's machine behaves differently from every launch on the machine that built it.

Test the packaged and notarized build on a machine that has never seen the project. A build that works only where it was created is the single most common surprise in this kind of work, and it is discovered at the worst possible moment, which is the moment it gets sent to somebody.

Before rolling it out to anyone

Three checks catch nearly everything that would otherwise arrive as a support message.

Run a complete sign in inside the app, not just a page load while the session from a previous browser visit is still valid. Then leave it until that session expires and confirm the renewal works inside the window, since redirect based renewals are exactly what a frameless window handles badly.

Click a link that leads somewhere else and confirm it opens in the default browser rather than replacing the app's own view. A file download and a print action are worth trying at the same time, because both behave differently outside a normal browser frame.

Finally, quit and relaunch, and check that the window returns where it was and the login survived. An app that forgets its session on every launch is worse than the tab it replaced, and it is a five minute fix if it is found before ten people have it.

Electron against the alternatives

Electron Tauri Safari, Add to Dock Dedicated builder
Engine Bundled Chromium System WebKit System WebKit Depends on the tool
Code to write A main process file A main process file, plus a Rust toolchain None None
Bundle size Hundreds of megabytes Single digit megabytes Nothing extra Small
Separate cookie store Yes Yes Yes Usually yes
Native menus, tray, global shortcut Yes Yes No Limited
Offline start Yes, if assets are bundled Yes No No
Signing and notarizing Required to distribute Required to distribute Not applicable Handled by the tool
Who patches the engine The person who built the app macOS updates macOS updates The tool's updates

The row that decides most cases is the last one. Anything with a bundled engine transfers responsibility for browser security patches to whoever built the app. Anything using the system engine keeps that responsibility with the operating system, where it is handled automatically.

Three questions that settle it

Is the app going to other people? If yes, signing and notarization are mandatory, and a builder that already handles them removes a recurring chore. The practical scope of that approach is easiest to judge from the Supported services list and the Features page, which show where a no code route stops being enough.

Does it need to work without a network? If yes, the assets have to be bundled, the app gains a version, and Electron or Tauri is the honest answer.

Does it need something a page cannot have? Tray presence, a global shortcut, a URL scheme, a watched folder. If the answer is no to all three, the remaining requirement is a separate window with its own icon and its own session, and that has been available without code since macOS Sonoma added standalone web apps in Safari.

What to change first

Spend one hour building the thin version, a window pointed at the existing URL, with node integration off and external links sent to the browser, and use it for a week before adding anything. If the only things still missing after that week are an icon, a name, and links that stay inside, a builder such as Kagemusha covers it without a repository to maintain.

Frequently asked questions

Can an existing web app be wrapped in Electron without changing its code?

Yes, if the app is loaded from its live URL. The page runs exactly as it does in a browser and does not need to know it is inside a wrapper. Code changes only become necessary when the front end is bundled into the app, because the API base URL then has to be configurable and requests become cross origin.

Why does a simple Electron app take up so much disk space?

Each application ships its own copy of Chromium and Node.js, so the size reflects the bundled runtime rather than the page. Tauri produces much smaller bundles by rendering through the WebKit engine already present in macOS, at the cost of following whatever version that system engine is on.

Does an Electron app receive browser security updates automatically?

No. An installed browser updates itself, but a bundled runtime stays at the version it was built against. Fixes arrive only when the app is rebuilt against a newer Electron release and that build reaches users, which is why an app compiled once and left alone gradually becomes an outdated browser.

Is Electron necessary just to get a site out of the browser tabs?

Usually not. Safari can add a page to the Dock as a standalone app with its own icon, its own session, and its own notification settings, and Chrome can install a page as an app. Electron becomes justified when native menus, a tray item, a global shortcut, filesystem access, or offline startup are genuine requirements.

Back to all posts