🎂 Path 1: The Shortcut
Just load your remote web app into Electron, like a browser
You scour the perimeter of the labyrinth, searching for the nearest entrance. As your eyes travel along the tiled, immaculate path, you notice that it continues, like a hallway, straight ahead without twist or turn. You spy an ornate, dazzling goblet, arrayed in jewels and gold, sitting atop a pedestal glinting off in the distance at the very end of the corridor.
• Turn Back
It’s important to remember what Electron is, aside from being so hot right now — Electron is Chromium and Node combined into one runtime, which you control by interacting primarily with two processes: the main / browser process, and the renderer process. The main process bootstraps the app and coordinates other processes in the background, while the renderer process is responsible for what the user sees and interacts with, much like a browser tab. The key difference here is that both processes have full access to Node built-ins like
Given this, by far the most straightforward approach would be to treat Electron like a browser, and just spawn a renderer process pointing at your remote web app.
What might this look like in code?
Let’s see where this path takes us:
You sprint at a brisk pace down the corridor, the goblet’s glittering growing more alluring with every step you take. You are now upon the threshold of the pedestal holding the goblet. You reach out your hand to claim your prize. Suddenly, the ground beneath you starts to shake, giving way to reveal that the floor is, in fact, lava. You start to reconsider your life choices.
Oh. Oh no. Where did we go so wrong?
There do seem to be some benefits to this approach: with only a few steps and lines of code you can have a seemingly functional desktop app, spring-boarded off the efforts of your web app. You might feel even further validated in following this path because many introductory Electron tutorials will have you try this out at some point — it is quite illustrative of Electron’s power and flexibility after all, and doesn’t require deep API knowledge.
Unfortunately, this path is more potholes than road: Electron’s superpowers raise quite a lot of security concerns when treated like a regular ol’ browser. As with any potential security concern, it can help to ask yourself: what are you trusting, and what kind of risks might that trust expose you to?
Don’t trust remote content, even if it’s from a trusted source.
Should you do it? 🙅 Nope, it’s a trap
The Shortcut is the simplest and most straightforward path, but it’s so insecure out of the box that it’s ultimately a nonstarter. If you’ve shipped a production Electron app currently built around this approach (and there are many 😬), you’ve shipped a recipe for disaster.
🖼 Path 2: Remote Isolation
Gate access to Node by isolating your remote web app
You spy another entrance close by, and peer around the hedge to see what appears to be a hallway lined with glass on all sides. You notice the branches of either side of the hedge swaying in the breeze, grazing the sides of the long glass passageway. Given where the last path led, you tread carefully and slowly along the translucent walkway towards the telltale glint of treasure off in the distance.
• Turn Back
So what was the problem with the first approach? We gave our remote web app full access to Node, and Node is extremely powerful. Normally it only runs in Backend Server Land, which is why it has unrestricted access to whatever the user that spawned it has access to, all the way down to the file system and network stack. We don’t really want to extend this same level of trust to a remote web app. Instead, we can gate our remote web app’s access to Node by loading it inside an isolated context, so that it’s no longer directly accessible from within the renderer process.
You might be thinking, isn’t half the point of Electron precisely that you can have access to Node from within a web page? We wouldn’t have much of a desktop app if all of a sudden we had no access to desktop superpowers like file system access! There is a way out, however — we can instead define an interop API via a preload script that gets run before Electron loads our guest page into the renderer. Within this preload script we still have access to Node, so we can use that to expose only the bare minimum functionality our web app needs, and nothing else. So what does this path look like?
- Load your remote web app inside of a
BrowserWindowor an Electron container (
- Disable Node integration and enable context isolation — this blocks access to Node and Electron built-ins from within the guest page
- Preload script is run before loading the remote guest page
- This preload script has access to Node built-ins
- Design an API that exposes the bare minimum surface area needed for your remote web app to work Desktop Magic ✨
We’ll focus on just a handful of config changes here, but it’s worth noting that to achieve full remote isolation you have to fiddle with a lot of knobs and levers — many more than I can detail in this post. In addition to disabling Node integration and enabling context isolation, you’ll also need to investigate Chromium process sandboxing and potentially OS-level app sandboxing (for Windows, Mac, and Linux) as well, to name a few considerations. For more details on security best practices, including a more thorough remote isolation pattern, please read through the entire Electron security guide.
To achieve full remote isolation you have to fiddle with a lot of knobs and levers
So, what might a very minimal, somewhat non-comprehensive implementation look like in code?
Let’s see where this path takes us:
You reach the source of the reflection in the distance only to discover that the glint is fire. The walls have collapsed, fires blaze where there ought to be none, and countless would-be adventurers lay unconscious throughout the crumbling facade. You begin to question your life choices.
Uh oh. This seemed so promising too!
This path definitely brings some benefits to maintainability. Much like The Shortcut, this approach shares the same client as your web app — this means that you get to take advantage of your remote web app’s continuous deployment pipeline. As a result, your Electron app “should” automatically have feature parity with your web app (barring any bugs, of course). Instead of having to build a separate native client and go out of your way to ensure it meets all the same specs as your web app, you just get all that, essentially, for free.
require and third-party dependencies, if your remote web app were compromised, the worst it could do is abuse your interop API (in theory — we’ll come back to this later).
Despite these benefits, there are some pitfalls: this approach does add some CPU and memory overhead, especially if you’re using an Electron container. Every container / context is its own separate process, with its own execution context and process block structures, and this can add up if you happen to be spawning multiple containers. You’ll also need to communicate between these processes and the main and root renderer processes. Electron provides a remote procedure call (RPC) mechanism for this, but since these remote method calls boil down to synchronous IPC message passing, relying on these remote objects for communicating between the guest page and parent renderer process will likely make your app really slow. These calls can block threads and your users will experience this as a hang — probably not what you want. You could also use IPC directly, but messages must be serializable, and any state changes would have to be synchronized between all processes.
Moreover, depending on which remote isolation approach you choose, you may be introducing a wellspring of endless bugs for yourself and your users. If you’re curious about some of the
webview bugs you might run into, take a gander at our earlier post about re-architecting the Slack Desktop app for 3.0.
There are also some maintainability pitfalls: when you load your entire remote web app into a container, it’s really difficult to reuse parts of your remote web app — it’s all or nothing. For example, let’s say your web app had a delightful modal dialog UI component, and you wanted to reuse that for a code path exclusive to desktop apps (e.g. handling connections to corporate proxies). How would you accomplish that with the Remote Isolation approach? Well… you’re probably going to have to copy and paste it into your desktop app repo. Since this path treats Electron like a wrapper around your remote web app, unless your web app exposes something on the level of a single UI component independent from any web-app-specific state, you won’t be able to access it for reuse.
This pattern also encourages implicit dependencies on the global scope. Since we’re injecting our interop API as a global on the
window object, it can be mutated by any module in our remote web app’s dependency tree at any point, with none of the guarantees of locks or safe concurrent access. This can make debugging a Sisyphean nightmare. Your app’s globals could also conflict with third-party dependencies (like some jQuery plugins), leading to unpredictable and untestable outcomes.
You must also be extremely mindful when designing your interop API. While in theory the worst that could happen if your remote web app were compromised is that your interop API could be abused, depending on the capabilities of that API and its design, that could be more than enough to wreak total havoc on your users’ machines. For example, don’t expose references to Node modules, because that is tantamount to requiring / importing them directly from your remote web app; don’t expose the require function itself; don’t expose generic IPC interfaces (because this would let your guest page send arbitrary messages to other processes).
It’s worth noting that these mitigations require serving your remote web app exclusively over HTTPS; as HTTP requests and responses are sent as plaintext, they are easily MITMed. It’s also really helpful to define a robust content security policy to prevent potentially untrusted code from violating Cross-Origin Resource Sharing (CORS).
Should you do it? 🤔 Maybe — only if you’re currently following The Shortcut (loading your remote web app directly into Electron)
Remote Isolation is definitely an improvement over The Shortcut path for security, but at the cost of performance and maintainability, and its implementation requires a lot of nuance and careful design. This is how Slack Desktop is architected today, but we’re actually in the process of moving to a completely different approach (we’ll come back to this later). If your goal is a hybrid app experience indistinguishable from a fast, polished native app, then I highly recommend avoiding this path.
📦 Path 3: Local Resources
Build your desktop app as a separate client composed of shared local resources
You stand at the entrance to a daunting sight — a gauntlet of moving platforms, sliding pedestals, and treacherous climbs. However, you see a few other adventurers blazing through this series of challenges with aplomb. You are filled with determination.
• Turn Back
Both The Shortcut and Remote Isolation approaches were predicated on sharing the same client as our remote web app (by loading it directly into a renderer or isolated context, respectively), but it seems like this may be at the root of many of their performance and maintainability tradeoffs. What if we avoid loading remote content and sharing the same client altogether? With this new path, we’ll build our desktop app as a completely separate client composed of resources extracted from our remote web app and shipped locally within our app bundle at build time. So, what would this look like?
For this approach, we’ve broken up our web app into discrete modules which are then published to some kind of publicly accessible rectangle. Next, we somehow import these into our Electron app. Finally, rather than pointing our BrowserWindow at a remote URL, we’re pointing it at a local file path that is responsible for booting the frontend of our desktop app. Unlike The Shortcut and Remote Isolation, the path of Local Resources brings along with it a lot of prerequisites and considerations before it can be implemented. Let’s break these down into rough steps:
- Decouple front-end client from back-end server
- Since we’re no longer loading our web app’s assets remotely, we can’t rely on the backend to make any changes to these assets at runtime (like injecting state). Instead, we’ll either have to ensure these changes happen at build time, or create new APIs if they must happen at runtime.
- Decouple UI components from data models
- Decouple front-end client from globally-scoped third-party dependencies
- Extract code to discrete, well-defined CommonJS / ES2015 modules
- Publish modules to some secure location that’s accessible from your Electron app repo (e.g. a package registry like npm Enterprise or Artifactory, or public npm for open source projects)
- Import these modules as regular ol’ dependencies to create a separate desktop client
- Ship these modules and other shared resources in your Electron app bundle
- Boot the app via a new
BrowserWindowpointing to local resources
Diving into the why of each step is a bit out of scope for this post, but here’s what a super minimal example might look like:
Let’s see where this path takes us:
You leap masterfully from platform to platform, avoiding traps and solving tricky puzzles along the way. You hear a voice emanate from the walls themselves:
“Your resourcefulness in overcoming this trial speaks to the promise of an Electron app developer… In the name of Slackbot I bestow upon you this reward.”
A treasure chest materializes before your eyes and begins to emit an orange glow.
Well that seems promising! Let’s see what benefits and tradeoffs come with this path.
There are some maintainability benefits to the Local Resources approach: it facilitates a de-duplication of efforts between our Electron app and our remote web app. Desktop-only code paths that would have had to duplicate components or logic (like the modal dialog UI component from earlier) can now reuse the same components and code paths from your web app. This allows you to be much more granular about what you want to use and how you want to use it in a desktop-specific context, without otherwise introducing subtle bugs and inconsistencies that may frustrate your users.
This approach also encourages building loosely-coupled, encapsulated modules as the building blocks for your app. This is fantastic in part because it makes them substantially easier to test, more predictable, and ultimately more maintainable as a result. No longer should you have to worry about one module’s instantiation interfering with another module’s operation (unless that was your intent).
There are some performance boons as well. You should see a much faster cold boot time compared to both the Shortcut and Remote Isolation paths — both approaches rely on a blocking remote network request to be able to boot successfully, so depending on your users’ network environments, that could either be really painful or preclude booting altogether. As a nice bonus, since this approach no longer requires a network connection to boot, this gets you part of the way towards having an offline mode for your desktop app, if that’s something you’re interested in.
Finally, there should be less resource overhead — fewer processes are spawned, especially compared to the Electron container approach, and you shouldn’t have to rely quite as much on IPC message passing for event handling. Electron apps don’t have to be resource-devouring monsters — the path you choose can have a huge impact on performance.
Let’s run this approach by our security gut check — what are we trusting, and what risks might that trust expose us to? In this case, we’re avoiding the question of trusting remote content altogether by exclusively loading local assets at runtime. We do still trust our local resources, however — any scripts located in our app bundle have privileged access to Node and Electron APIs, so we’re trusting that the contents of our app bundle haven’t been tampered with locally. These local resources now include modules extracted from our web app, so we need to be confident that those modules and any of their third-party dependencies aren’t doing sketchy stuff behind the scenes on users’ machines.
There are still some pitfalls to this approach, even though it mitigates many of the tradeoffs involved in the path of Remote Isolation. This path would require engineers working on the web app to adopt some kind of package publishing and versioning process — this could take a not insignificant amount of time and training to implement.
Relatedly, this path will force you to make some kind of call between backing your published packages with many repos or a monorepo. Say you’re going to publish shared code to a package registry — where will the code that gets published to that registry live? Will multiple packages live colocated in your web app repo, only being published as distinct units at publish time? Or will you split each module out into its own repo? And if you do that, how will you pull those modules back into your web app repo? How will you ensure each module repo meets the same continuous integration (CI) standards as your web app repo? These are questions you will end up having to address.
One major pitfall of this approach is that you no longer get continuous deployment for free. With the path of Remote Isolation, we were able to leverage our remote web app’s continuous deployment to maintain feature parity with the browser-based web app without extra intervention. If a bug fix were pushed to the web app, for example, your Electron app users would automatically get it too (on reload). Unfortunately, with Local Resources, that’s no longer the case. As opposed to the rapid iteration of web app development, traditional desktop app development consists in shipping discrete releases, with much longer turnaround times for QA and App Store review cycles. Ultimately, this means that your Electron app will always lag behind your web app, to some degree.
How granular do you want to get in your component or dependency trees? For example, you could extract every single UI component to separate packages for reuse, and assemble your Electron app up from the smallest, most atomic component, bit by bit, until you have built a comparable client from the same pieces as your remote web app. Conversely, you could just extract your root UI component (ie. the entire web app) and inject your desktop interop API at the top-most level, and trust that the pieces are composed in the right way on the web app side to work with Electron. Either approach is totally valid.
How would you maintain the interfaces provided by these shared modules over time? Adhering to Semantic Versioning to communicate breaking and non-breaking changes helps, but it depends on engineers actually bumping the major versions of these modules when there are indeed breaking changes. Adding some automation and tooling, like static type checking that warns if an API contract breaks without bumping the major version number of a package can help, but this is really a human challenge.
Does your app pull in remote data, and not just remote static assets? Is any of that data user generated? Do you need to handle use cases that entail loading third-party websites, like single sign-on (SSO)? If any of these things are true, you might still need to run in an isolated context to prevent untrusted content from having privileged access to Node. Indeed, you might also need to verify the local contents of your app bundle to ensure they haven’t been tampered with, for maximum paranoia. So, while Local Resources mitigates security concerns, it doesn’t quite eradicate them — always ask yourself what you’re trusting, and what risks that trust might expose you and your users to.
Should you do it? 😄 Sure, give it a shot!
There are many prerequisites and considerations to take into account, but the boons to performance and maintainability are worth it — so long as you’re comfortable with the tradeoffs made to continuous deployment and the level of trust extended to local modules shared with your web app.
🍱 Path 4: Hybrid
Ship a local snapshot of your web app and update it remotely
You stand at the opening of a yawning abyss. A plexiglass bridge extends out over it. As you cross, you notice something resplendent in the distance, its shine filling you with more determination with each step you take. At the end of the bridge lies a grand door — it was its doorknob that you spotted from a distance.
• Turn Back
With both the paths of Local Resources and Remote Isolation, we had to make some kind of sacrifice in maintainability, performance, or security. What if there were some kind of hybrid approach we could use, combining the benefits of each while avoiding most of their pitfalls? With this path, we’re aiming to strike a balance between the trust and continuous deployment benefits of Remote Isolation and the maintainability and performance benefits of Local Resources. To accomplish this, we can ship a local snapshot of resources that we’ve extracted from our web app, and then update them remotely in the background, but all within an isolated context. Phew, that’s quite a mouthful — I think it helps to think of this as combining Remote Isolation and Local Resources, and adding background updates. So, what would this look like?
- At build time, ship the most current versions of shared modules in your app bundle
- At run time, boot from these local resources in an isolated context
- At run time, check for updates in the background (via a ServiceWorker or custom updater) and verify them
- Download and install an update consisting of only what’s changed
- (Optionally) Hot load changes
Let’s see how this path goes:
Upon opening the door, you stand in a glittering chrome room, made of dazzling forms that seem to fold in on themselves in impossible shapes — a room made of labyrinths. You begin to wonder if perhaps the real treasure of the labyrinth is the journey itself.
Hmmm, this seems like a Deep Future approach, but rather promising!
Unlike the path of Local Resources, with the Hybrid path we get to take advantage of our web app’s continuous deployment pipeline again. Moreover, we get the same performance benefits from booting from the bundled snapshot of shared modules, but with the rapid iteration of web app development. Whenever a shared module is published, our desktop app should be able to pull in these updates without having to rebuild or re-release the Electron and native bits along with them — powerful stuff. Unlike with Remote Isolation, our desktop app is no longer doomed to an all-or-nothing approach to reusing parts of the web app, preserving the maintainability benefits of Local Resources.
Let’s see how our security gut check fares — what are we trusting, and what risks does that trust expose us to? Where before we were trying to avoid the question of trusting remote content, with the Hybrid path we instead explicitly deny it. In addition to isolating our remotely fetched shared modules, we’re also isolating the snapshot of shared modules we bundle at build time. This means that all shared code should be running under isolation, whether local or remote. We are still trusting that our local Electron shell code is legitimate, but we’re no longer trusting anything that loads inside of a renderer process.
Cons / Other Considerations
Despite the benefits, this path can introduce a bit of a wrinkle into your update process. Instead of one updater updating both the Electron shell and shared web app modules together in one fell swoop, you now have two distinct update paths: one that updates the Electron shell and any native Node modules, and one that updates shared web app modules. You’ll need to be mindful of how they could interact to avoid introducing incompatibilities.
Questions of granularity also rear their heads again — do you want to update each shared module on a per-package basis (ie. downloading each updated package tarball in a separate request)? Or do you want to instead download a collated bundle of the updated modules in one request? Although it might be tempting to ship your app with a full-blown package manager like npm to handle updates for you, this would likely be playing with fire. In addition to the security risks of running arbitrary package lifecycle scripts on users’ machines, these are developer tools designed to take full advantage of your machine’s hardware, and could end up taxing users’ machines for an operation that should be relatively inexpensive and seamless.
If you do decide to go the bundle route, should this just be a simple archive of package registry tarballs? Or should they be a precompiled asset ready to load straight into the browser, generated with a tool like webpack or Rollup.js? If the latter, how might you integrate this with code splitting, or hot loading?
When modules are updated, how quickly do you want your users to get the latest code? You might want to consider different caching strategies for shared module updates compared to Electron shell updates. For example, maybe you want to boot with the latest cached assets, and then fetch updates in the background to be used on next boot. Or maybe, to bring in granularity again, some module updates are higher priority than others, and should be able to immediately invalidate any cached versions on fetch. If that’s the case, what should “immediate” look like? Should your app prompt the user to restart the app or reload the window to get the latest assets? Or should your app hot load the latest assets and optionally communicate the update results to your user?
These are questions that’ll likely arise as you design your update flow, and we’re still working out how all the pieces will fit together.
Should you do it? 😍 Probably!
We’re currently far from realizing the full potential of Electron as a cross-platform app development platform — this Hybrid approach seems like a promising way to bring us closer to that dream without sacrificing maintainability, performance, or security. Indeed, this is an approach we’re really excited about at Slack — but since we’re still actively investigating we can’t definitively recommend it, since we don’t entirely know what all the tradeoffs might be. Nonetheless, it’s an exciting work-in-progress.
To recap, The Shortcut seemed the most straightforward way to follow our desktop dreams, but turned out to be a rather nightmarish trap; Remote Isolation was a much safer approach to loading a remote web app in an Electron shell, but came with a host of maintainability and performance woes; Local Resources raised a ton of prerequisites and considerations, but brought some fantastic performance and maintainability improvements at the cost of continuous deployment and isolation; and the Hybrid path weaved Local Resources together with Remote Isolation to mitigate the tradeoffs of each individually.
With the incredible power of Electron comes the responsibility, our responsibility, to navigate Interop’s Labyrinth so our users won’t find themselves trapped in the pitfalls of the paths we’ve chosen. In Interop’s Labyrinth, we must all face the choice between what is right, and what is easy.
If any of this sounds interesting and/or mildly horrifying to you, come work with us!