Building a fast Electron app with Rust

When I built Finda, I wanted it to be fast — specifically, to respond to all user input within 16 milliseconds.

Given this goal, you might be surprised to learn that Finda is built with Electron, a framework that’s often decried for being the opposite of fast.

In this article, I’ll explain how Finda uses Rust to leverage Electron’s strengths (easy packaging, access to complex OS-specific APIs, the visual capabilities of the browser) while minimizing its downsides of unpredictable latency and memory usage.

Design considerations

Before diving into the technical details, it’ll help to understand the design goals of Finda itself.

Finda supports a single interaction: You type stuff, and it finds things — browser tabs, text editor buffers, local files, browser history, open windows, whatever:

The goal is for Finda to feel not like an application, but more like Command-Tab, just part of the operating system itself, appearing instantly when needed and afterwards disappearing just as quickly.

There’s no need for menus, multiple windows, buttons, or any kind of native UI. Really, Finda’s interaction requires only:

Otherwise, Finda should just invisibly chill in the background.

Non-Electron alternatives

Given these minimal requirements, I considered my options.

Native OS X: I decided against this option early, for two reasons:

1) I want the option to port Finda to Windows and (I can’t believe I’m saying this) Linux, since beta testers asked if they could buy a version for their respective platforms.

2) To use XCode for native development, I’d have to upgrade OS X, which would almost certainly break my computer in new and different ways than how it’s currently broken (and which I’ve made peace with).

Game-like: I’ve written one pixel shader in my life (a gradient from white to black), but hey, games are fast, maybe that’d work? I explored a few options and decided to try a prototype using ggez, a wonderful Rust game library built on top of SDL.

I found the API approachable for a graphics novices like myself (setting colors and drawing rectangles, rather than pipelines of structs of bytes of shaders that take bytes of…), but soon realized that the primitives were still going to require quite a bit of work on my part to compose into a decent application.

For example, text can be rendered given a string, font size, and typeface. But Finda highlights the matches as you type:

which means that I’d need to juggle multiple typefaces and colors, as well as track the bounding box of each drawn substring to layout everything.

Beyond rendering, I also ran into difficulties on the operating system integration front. I found it difficult to:

None of these are really “games” problems, and it didn’t seem like switching to another framework like GLUT (OpenGL) would be any better than ggez (SDL).

Electron: I’ve built apps using Electron before, and I knew it’d meet Finda’s requirements. Browsers were originally designed to layout text, and Electron provides extensive window options and a one-line API for global shortcuts.

The only unknown was performance, which is the subject of the rest of this article.

Architecture

The architectural tl;dr is that Electron is used as a user interface layer, with a Rust binary handling everything else.

When Finda is open and a key is pressed:

1) The browser invokes a document onKeyDown listener, which translates the JavaScript keydown event to a plain JavaScript object representing the event; something like:

{name: "keydown", key: "C-g"}

2) This JavaScript object is passed to Rust (more on this later), and Rust returns another plain JavaScript object representing the entire application state:

{ 
  query: "search terms",
  results: [{label: "foo", icon: "bar.png"}, ...],
  selected_idx: 2,
  show_overlay: false,
  ...
}

3) This JavaScript object is then passed to React.js, which actually renders it to the DOM using <divs>s and <ols>s.

Two things to note about this architecture:

First, Electron doesn’t maintain any kind of state — from its perspective, the entire application is function of the most recent event. That’s only possible because the Rust side maintains Finda’s internal state.

Second, these steps occur for every user interaction (keyup and keydown). So, to meet the performance requirement, all three steps together must complete in under 16ms.

Interop

The interesting bits are in step #2 — just what does it look like to call Rust from JavaScript?

I’m using the awesome Neon library to build a Node.js module with Rust.

From the Electron side, it feels just like calling any other kind of package:

var Native = require("Native");
var new_app = Native.step({name: "keydown", key: "C-g"});

The Rust side of this function a bit more complicated. Lets walk through it in pieces:

pub fn step(call: Call) -> JsResult<JsObject> {
  let scope = call.scope;

  let event = &call.arguments.require(scope, 0)?.check::<JsObject>()?;

  let event_type: String = event
      .get(scope, "name")?
      .downcast::<JsString>()
      .unwrap()
      .value();

JavaScript has several semantics that don’t map neatly to Rust’s language semantics (e.g., the arguments object and infamous this dynamic variable).

So, rather than try to map the JS call to a Rust function signature, Neon passes your function a single Call object, from which the details can be extracted. Since I’ve written the calling (JS) side of this function, I know that the first and only argument will be a JavaScript object which will always have a name key associated with a string value.

This event_type string can then be used to direct the rest of the “translation” from the JavaScript object to the appropriate Finda::Event enumeration variants:

match event_type.as_str() {
    "blur" => finda::step(&mut app, finda::Event::Blur),
    "hide" => finda::step(&mut app, finda::Event::Hide),
    "show" => finda::step(&mut app, finda::Event::Show),

    "keydown" => {
        let s = event
            .get(scope, "key")?
            .downcast::<JsString>()
            .unwrap()
            .value();
        finda::step(&mut app, finda::Event::KeyDown(s));
    }

...

Each of these branches also invokes the finda::step function, which actually updates the application state in response to the event — changing the query and returning relevant results, opening the selected result, hiding Finda, whatever.

(I’ll write more about the Rust details in future blog posts — sign up for my mailing list or follow @lynaghk if you’d like to get notified when that happens.)

After that application state has been updated, it needs to be returned to the Electron side for rendering. This process looks similar, but in the other direction: Translating Rust data structures into JavaScript ones:

let o = JsObject::new(scope);

o.set("show_overlay", JsBoolean::new(scope, app.show_overlay))?;
o.set("query", JsString::new(scope, &app.query).unwrap())?;
o.set(
    "selected_idx",
    JsNumber::new(scope, app.selected_idx as f64),
)?;

Here we’re first creating the JavaScript object that’ll be returned to Electron and then associating keys with some primitive types.

Returning the results (an array of objects) requires a few more hoops: The size of the array must be declared in advance and the Rust structure explicitly enumerated, but overall it’s not too bad:

let rs = JsArray::new(scope, app.results.len() as u32);

for (idx, r) in app.results.iter().enumerate() {
    let jsr = JsObject::new(scope);
    jsr.set("label", JsString::new(scope, &r.label).unwrap())?;

    if let Some(ref icon) = r.icon {
        jsr.set("icon", JsString::new(scope, &icon.pathname).unwrap())?;
    }

    rs.set(idx as u32, jsr)?;
}

o.set("results", rs)?;

Finally, at the end of this function the JavaScript object is returned:

Ok(o)

and Neon handles all the details for passing it to the caller on the JavaScript side.

Performance verification

So, how does all this machinery perform in practice? Here’s a typical trace for a single keypress, as seen on the “performance” tab of Chrome DevTools (which is built-in to Electron):

DevTools Performance trace of a single keypress

Each step is labeled: 1) translating the keypress to an event, 2) processing the event in Rust, and 3) rendering the results with React.

The first thing to note is the green bar at the top, which shows that all this happens in under 14ms.

The second thing to note is oh dang Rust is fast! The Rust interop (section #2, with the actual Native.step() call highlighted in the flamegraph) completes in less than a millisecond.

This particular keydown event corresponds to adding a letter to my query, which means that in this single millisecond Finda:

If you don’t believe me that it’s that fast, you can download and try it yourself.

(Of course, my 2013 Macbook Air’s SSD isn’t fast enough to even enumerate all those files in a millisecond — Finda transparently builds and maintains an index. More on that in a future post.)

The bulk of the time is spent rendering: About 8ms for React (the render bar at the bottom of the flamegraph) and 4ms for the browser itself to layout (purple), paint (green), and load thumbnail images from disk/cache (rightmost yellow).

The specific performance numbers vary between events, but this trace is typical: Rust takes a few milliseconds to do the “actual work”, the majority of the time is taken up by rendering, and the entire JavaScript execution consistently finishes under 16ms.

Reflection on performance

Given these performance numbers, one perspective would be to reduce the response time by dropping React (and perhaps the DOM entirely) and instead handle layout and rendering manually with a <canvas> element.

However, there are some seriously diminishing returns. Putting aside whether or not a human can differentiate a 15ms response and a 5ms response, it’s likely that some low-level operating system / graphics driver / LCD physics dominates the actual response time at this scale.

Another perspective is that we have the extra budget — we can “waste” it on a slower rendering path if that path has other benefits. And in the case of Electron, it certainly does: In addition to the easy to use, built-in profiling tools we just saw, the DOM and CSS provide a wonderful amount of runtime malleability. It’s easy to open up the inspector and play with different typefaces, colors, and spacing:

For an entirely data-driven application like Finda, having the ability to visually sketch and play in the production medium is essential — it’s impossible to effectively prototype a search-based interaction by pushing pixels around a graphic design tool.

Perhaps someone who knows OpenGL like the back of their hand can do this kind of sketching in Emacs or a game dev could throw it together in Unity.

For me, I wouldn’t have been able to imagine, prototype, and release Finda without having both Electron and Rust. They’re spectacular technologies, so a big thanks to everyone who’s contributed to them!

Wrap up

Electron and Rust have turned out to be a great fit for the design constraints of Finda.

Electron makes it easy to build and distribute a desktop app, shielding me from the tedious details of font rendering and low-level OS hotkey and window APIs.

Rust makes it easy to write fast, safe, low-level data structures, and encourages me to think about memory and performance in a way that I’m normally oblivious to when wearing my usual JavaScript / ClojureScript hat.

Together, they make a powerful combination: Try out Finda to experience the speed yourself!

If you try this hybrid Rust/Electron approach on your own projects, please write me! I’d love to hear about what you’re working on and help any way I can!

Further reading

Thanks

Thanks to Nikita Prokopov, Saul Pwanson, Tom Ballinger, Veit Heller, Julia Evans, and Bert Muthalaly for thoughtful feedback on this article.