Electron’s ‘remote’ module considered harmful
[EDIT]: I gave a talk on this at Covalence Conf 2020, which you can watch here if you’d like!
Since the very earliest versions of Electron, the remote
module has been the go-to tool for communicating between the main and renderer processes. The basic premise is this: from the renderer process, you ask remote
for a handle to an object in the main process. Then you can use that handle just as if it were a normal JavaScript object in the renderer process—calling methods, awaiting promises, and registering event handlers. All the IPC calls between the renderer and main process are handled for you behind the scenes. Super convenient!
… until it isn’t. Almost every nontrivial Electron app that has used the remote
module—Slack included—has ended up regretting their decision. Here’s why.
#1 — It‘s slow.
Electron, being based on Chromium, inherits Chromium’s multi-process model. There are one or several renderer processes, which are responsible for rendering HTML/CSS and running JS in the context of a page, and a single main process, which is responsible for coordinating all the renderers and executing certain operations on their behalf.
When a renderer process accesses a remote object, for example to read a property or call a function, the renderer process sends a message to the main process asking it to execute that operation, and then blocks waiting for the response. That means that while the renderer is waiting for the result, it can’t do anything but twiddle its thumbs. No parsing incoming network data, no rendering, no handling timers. It’s just waiting.
The average time to access a property on a remote object is about 0.1 ms on my machine. For comparison, accessing a property on an object that’s local to the renderer takes about 0.00001 ms [replicate]. Remote objects are ten thousand times slower than local objects. Let me put that in big text, because it’s important.
Remote objects are ten thousand times slower than local objects.
Doing one or two of these 0.1 ms calls every now and then isn’t really a problem—0.1 ms is still pretty fast compared to the 16 ms you get if you want to stay inside a single frame. That’s a budget of 160 calls to remote
objects per frame, assuming you’re not doing anything else.
… but it’s really easy to accidentally be making many more remote calls than you might expect. For example, consider the following code, which imagines a custom domain object present in the main process being manipulated from a renderer:
// Main process
global.thing = {
rectangle: {
getBounds() { return { x: 0, y: 0, width: 100, height: 100 } }
setBounds(bounds) { /* ... */ }
}
}// Renderer process
const thing = remote.getGlobal('thing')
const { x, y, width, height } = thing.rectangle.getBounds()
thing.rectangle.setBounds({ x, y, width, height: height + 100 })
Executing this code in the renderer process involves nine round-trip IPC messages:
- the initial
getGlobal()
call, which returns a proxy object, - getting the
rectangle
property fromthing
, which returns another proxy object, - invoking
getBounds()
on the rectangle, which returns a third proxy object, - getting the
x
property of the bounds, - getting the
y
property of the bounds, - getting the
width
property of the bounds, - getting the
height
property of the bounds, - getting the
rectangle
property ofthing
again, which returns the same proxy object as we got in (2), - invoking
setBounds
with the new value.
These three lines of code—not a single loop!—take nearly a whole millisecond to execute. A millisecond is a long time.
It’s certainly possible to optimize this code to reduce the number of IPC messages needed to accomplish this particular task (and in fact, some special internal Electron data structures like the bounds object returned from BrowserWindow.getBounds
have magic properties that make them a bit more efficient). But code like this can easily find its way into the dusty corners of your app and end up producing a “death by a thousand cuts” effect—code that seems unsuspicious on inspection is in fact much slower than it appears. This problem is compounded by the fact that those proxy objects can end up in all sorts of places if they’re returned from the function that created them, resulting in those slow remote IPCs being invoked from places very far from the initial call to remote.getGlobal()
.
#2 — It creates the potential for confusing timing issues.
We generally think of JavaScript as being single-threaded (the new worker threads module in Node aside). That is, while your code is running, no other things can be happening. This is still true in Electron, but when using the remote
module, there is some subtle trickiness which can result in race conditions where you don’t expect them to exist. For example, consider this relatively common JavaScript pattern:
obj.doThing()
obj.on('thing-is-done', () => {
doNextThing()
})
Where doThing
kicks off some process that will eventually trigger the thing-is-done
event. The http
module in Node is a good example of a module that’s commonly used in this way. This is safe in normal JavaScript because there’s no way that the thing-is-done
event can be triggered until your code is finished running.
If obj
is a proxy to a remote object, however, then this code contains a race condition. Say doThing
is an operation that can complete very quickly. When we call obj.doThing()
on the proxy object in the renderer process, the remote
module sends off an IPC to the main process under the hood. doThing()
is then invoked in the main process, and it kicks off whatever thing it does, returning undefined
as the return value to the renderer process. Now there are two threads of execution: the main process, which is doing thing
, and the renderer process, which is about to send a message to the main process requesting that an event handler be added to obj
. If thing
completes especially quickly, it may happen that the thing-is-done
event is triggered in the main process before the message arrives informing the main process that the renderer process is interested in that event.
Both the main process and the renderer process here are single-threaded, normal JavaScript. But the interaction between them causes a race condition where the event is triggered between the call to doThing()
and the call to on('thing-is-done')
.
If that seems confusing and subtle, that’s because it is. Electron’s own test suite contained many different versions of this kind of race condition until a recent drive to reduce test flakiness ferreted them out.
#3 — Remote objects are subtly different to regular objects.
When you ask for an object from the remote module, you get back a proxy object—one that stands in for the real object on the other side. The remote module does its best to make that object seem as if that object were really present in the current process, and it does a pretty good job, but there are a lot of weird edge cases that make remote objects different in ways that will work fine the first 99 times and fail in some sort of extremely difficult-to-debug way on the 100th. Here are a few examples:
- Prototype chains aren’t mirrored between processes. So, for instance,
remote.getGlobal('foo').constructor.name === "Proxy"
, instead of the real name of the constructor on the remote side. Anything remotely clever involving prototypes is guaranteed to explode if it touches a remote object. NaN
andInfinity
aren’t correctly handled by the remote module. If a remote function returnsNaN
, the proxy in the renderer process will returnundefined
instead.- Return values from callbacks that run in the renderer process aren’t communicated back to the main process. When you pass a function as a callback to a remote method, then calling that callback from the main process will always return
undefined
, regardless of what the method in the renderer process returns. This is because the main process cannot block waiting for the renderer process to return a result.
Chances are, you won’t encounter any of these subtle differences when you’re using the remote module for the first time. Or maybe even the hundredth time. But by the time you’ve come to realize that some corner-case of how the remote module works is causing the bug you’ve been trying to figure out for the list six hours, it’ll be too late to easily change the decision to use remote
.
#4 — It’s a security vulnerability waiting to happen.
A lot of Electron apps never intentionally run untrusted code. However, it’s still a wise precaution to enable sandboxing in your app—it’s quite common to display arbitrary user-controlled images, for example, and it’s not unheard of for, say, PNG decoding to contain bugs.
But a sandboxed renderer is only as secure as the main process makes it. The renderer communicates with the main process to request that actions be performed on its behalf—for example, opening a new window or saving a file. When the main process receives such a request, it makes a determination about whether the renderer ought to be allowed to do that thing, and if not, it will ignore the request and unceremoniously shut down the renderer process for bad behavior. (Or possibly just deny the request, depending on how severe the infraction is.) There’s a clear security boundary here: no matter what the renderer process asks, the main process is in charge of deciding whether or not to allow it.
The remote
module tears a great big Mack truck-sized hole in this security boundary. If a renderer process can send requests to the main process that say “please get this global variable and call this method”, then it’s possible for a compromised renderer process to formulate and send a request to ask the main process to do whatever it wants. Effectively, the remote
module makes sandboxing almost useless. Electron provides an option to disable the remote
module, and if you’re using the sandbox in your app, you should definitely also be disabling remote
.
I haven’t even touched on a major class of issue: the inherent complexity of remote
's implementation. Bridging JS objects between processes is no small task: consider, for example, that remote
must propagate reference counts between processes to prevent objects from being GC’d in the other process. This task is challenging enough that it can’t be accomplished without hefty bookkeeping and delicate slabs of C++ (though it may become possible to do with pure JavaScript once WeakRefs are available). Even with all that machinery, remote
doesn’t (and very likely never will) be able to correctly GC cyclic references. Few people in the world fully understand the implementation of remote
, and fixing bugs that occur in it is Very Hard™.
The remote
module is slow, race-condition-prone, produces objects that are subtly different to regular JS objects, and is a huge security liability. Don’t use it in your app.
OK then, what should I do instead?
Ideally, you should minimize the usage of IPC in your app—it’s better to keep as much work as you can in the renderer process. If you need to communicate between multiple windows in the same origin, you can use window.open()
and script them synchronously, just the same as you can on the Web. For communicating between windows in different origins, there’s postMessage
.
But when you really just need to call a function in the main process, I’d recommend you use the new ipcRenderer.invoke()
method that’s available as of Electron 7. It works similarly to the venerable ipcRenderer.sendSync()
, but it’s asynchronous—meaning that it won’t block other things from happening in the renderer. Here’s an example of converting from a remote
-based system for loading a file to one based on ipcRenderer.invoke()
:
Before, with remote:
// Main
global.api = {
loadFile(path, cb) {
if (!pathIsOK(path)) return cb("forbidden", null)
fs.readFile(path, cb)
}
}// Renderer
const api = remote.getGlobal('api')
api.loadFile('/path/to/file', (err, data) => {
// ... do something with data ...
})
After, with invoke:
// Main
ipcMain.handle('read-file', async (event, path) => {
if (!pathIsOK(path)) throw new Error('forbidden')
const buf = await fs.promises.readFile(path)
return buf
})// Renderer
const data = await ipcRenderer.invoke('read-file', '/path/to/file')
// ... do something with data ...
Or, using ipcRenderer.send (for Electron 6 and older):
Note that this approach can only handle a single outstanding request at a time, unless you do some bookkeeping to track which response belongs to which request. (invoke()
handle matching responses to requests automatically.)
// Main
ipcMain.on('read-file', async (event, path) => {
if (!pathIsOK(path))
return event.sender.send('read-file-complete', 'forbidden')
const buf = await fs.promises.readFile(path)
event.sender.send('read-file-complete', null, buf)
})// Renderer
ipcRenderer.send('read-file', '/path/to/file')
ipcRenderer.on('read-file-complete', (event, err, data) => {
// ... do something with data ...
})
// Note that only one request can be made at a time, or else
// the responses might get confused.
This is a small example, and what you need to do with IPC may be more complicated and not translate so neatly to invoke
. But writing your IPC handlers this way will leave you with a clearer, easier-to-debug, more robust, and more secure app.