Architecting a web app to "just work" offline
Architecting a web app to "just work" offline

This is our first post on making Superhuman the fastest and most reliable email experience in the world. Here, we go over an offline architecture that is fast, robust, and maintainable. In the next post, we'll cover how to detect and convey connection status.

At Superhuman, we're building the fastest email experience in the world. Our goal is to make you brilliant at email, anywhere and everywhere you are.

Imagine you're out and about — on a plane, in a café, or the back of an Uber.

Your internet connection is flaky at best. Packets drop, requests fail, and latency is high.

You try your best to make Gmail work. But when you read emails, some revert to unread. When you hit Send, you're stuck for seconds. And when you try to search, you're dead in the water.

Superhuman gives you a new way to work with Gmail. It works perfectly offline. It works even when online, and your internet connection is terrible. (A terrible connection is, by the way, much harder to support than a connection that is just offline.)

Here's the twist: Superhuman is a web application.

Historically, you couldn't build robust offline web experiences. For example, you couldn't reliably load code, and you couldn't store meaningful amounts of data.

However, recent browser technologies have made these possible. In this and an upcoming post, I'll explain how to make complex web applications work offline.

Essential technologies

Making complex web applications work offline requires the following pieces:

  1. An ability to load code offline
  2. An ability to store gigabytes of data
  3. An ability to detect and convey connection status
  4. An architecture that makes developing offline features very easy

Loading Code

To load code offline we use ServiceWorkers, a programmable proxy between the application and the network. We use this layer to serve cached assets when offline.

To learn more about ServiceWorkers, see this great introduction.

Storing Gigabytes

The client caches everything it needs to display an email, including attachments, images, and message content. To store this data we use a combination of CacheStorage, WebSql, and LocalStorage.

Each storage technology has very particular characteristics, and so it is important to match the technology and the data. For example, we store application code and attachments in CacheStorage, whereas we store email metadata and message content in WebSql.

To learn about browser storage technologies, see this overview.

Conveying Status

We will fully cover detecting and conveying connection status in the next post. To get started, you can use navigator.onLine.

This API works reasonably well, but it often claims the user is online when they are not. To counteract this, we built a custom solution that detects false positives and handles partial connectivity — leading to a more robust user experience.

Offline Architecture

We wanted our offline architecture to have the following three qualities:

  • Fast: the application should respond immediately to the user
  • Robust: failures should rarely happen and should be handled with grace
  • Easy to build: features have a single code path, whether online or offline

With these qualities in mind, we developed an architecture around modifiers and modifier queues.

The idea is simple: when the user takes any action, we model the data change directly as a modifier. Modifiers, in turn, are processed in modifier queues.

Let's use the "Star Email" action as an example. This modifier is used when the user wants to star an email.

A modifier has two responsibilities:

First, a modifier should save the change to the network. For this we use the persist method:

class StarThreadModifier {
  persist() {
    fetch(
      GMAIL_API_URL + "/threads/" + this.threadId + "/modify",
      {method: "POST", body: JSON.stringify({addLabel: "STAR"})}
    )
  }
}

Second, a modifier should make it appear as if the save has already gone through. To do this we use the modify method to change local data:

class StarThreadModifier {
  modify(thread) {
    for (var message in thread.messages) {
      if (message.id == this.messageId) {
        message.labelIds.push("STAR")
      }
    }
  }
}

Saving changes to the network via the persist method happens asynchronously. After the backend servers are updated, we update our cached copy of the data.

On the other hand, changing local data via the modify method always happens synchronously. As soon as the user stars an email, the rest of the application sees the new data. This helpfully avoids an entire class of race conditions.

Modifiers are also synchronously put in a queue to keep them in order. We create one modifier queue per email thread. This ensures that both the application and the backend see a consistent state of the world.

For example, if the user "stars" an email and then immediately "unstars" it, we can be certain that the email will end up without a star in both the application and the backend.

Putting the modifier and modifier queue together, our full system looks like this:

In summary:

  • Modifiers are created on each user action
  • Modifiers in the queue are combined with cached emails, to present an updated view of the data to the application
  • Modifiers modify and persist in the order that the user created them

Since we ensure that modify is a pure function, and persist is idempotent, our architecture has two robustness properties:

  • The ability to retry: if we fail to save to the network, we can just retry the persist method (and again… and again…)
  • The ability to roll back: if retrying the persist method fails permanently (even after being retried), we can remove the modifier from the queue, recombine the cached email data with the rest of the modifiers, and the UI will update to show that the modifier never ran

When the user is offline, we hold modifiers in the queue, and we don't try to persist them until they are back online. Since the user interface updates immediately, this is completely transparent to the user. It is also completely transparent to the programmer, and enables us to write features as though the user were always online.

Saving Modifiers

If the user closes the tab while modifiers are in memory, we would lose these changes. To ensure that Superhuman never loses work, we also save modifiers to disk.

Saving modifiers to disk makes persisting them more complex. If the user opens multiple tabs, and each tab loads the modifier queue from disk, then multiple tabs could persist the same modifier — or persist them out of order.

To handle multiple tabs, we have a single process responsible for persisting all modifiers, implemented with a Chrome extension. This approach has a great side effect: if you close Superhuman while modifiers are still persisting, your work will still be saved to the network.

Conclusion

We've gone into some detail about how we make Superhuman the best email experience for people on the go. We've used this modifier architecture for the last 6 months and we're very pleased to report that it works excellently — especially at 20,000 feet in the air!

Stay tuned for Part 2 where we'll go into more detail on how we detect and convey connection status.