Architecting an Intuitive and Powerful Offline Experience in Notejoy
Today I'm excited to announce the launch of offline support in Notejoy, our collaborative notes app for individuals and teams. You can now view, edit, and create notes while offline and have it all seamlessly sync whenever you come back online. More importantly, we've also made the overall Notejoy experience much faster by first loading notes from your local device before also checking Notejoy's servers for any changes. This is an important milestone for us, as offline support has become our #1 requested feature over the past year, so it's great to finally get this in the hands of our customers. For those interested, I wanted to share a behind-the-scenes look at how we thought about the requirements for offline support, the design principles we employed, and the ultimate architecture we settled on to develop a first-class offline experience in Notejoy.
The industry landscape
We began this project by surveying the offline experiences of popular apps in the markets we play in, including notes, docs, and wikis. As we researched the offline capabilities in each of these apps, a pretty clear pattern started to emerge. We found that apps mostly fell in one of two categories: online-first or offline-first experiences.
Online-first apps create phenomenal connected experiences that truly show off what the Internet is capable of. But when it comes to their offline support, the experiences felt like little more than a set of crippled fallback mechanisms. Google Docs is an exemplar of the online-first experience. It started the cloud docs revolution by bringing real-time collaborative editing to the masses and continues to be great at this very task. But if you've ever tried to use Google Docs offline, it'll be clear why Microsoft Word is still a far superior experience. First, to take advantage of Google Docs offline, you need to download a specific browser extension that of course is only available in Chrome. You then need to configure Google Drive to sync your files offline. Despite having gone through this setup, it turns out Google Drive will then only sync a recent subset of your documents to your local device. But its a very non-obvious recent subset. For example, if you go into Google Drive's own Recent filter, you'll find that not all your most recently accessed documents are available offline! If there are specific files you want to ensure are available while offline, you need to go to that file in Google Drive and mark it as available offline, leaving the user to remember and manage each and every time they might need a file available offline. Even after you've done all this, you'll likely encounter bugs that tell you the document must be reloaded or for some reason is no longer available offline.
In stark contrast to this is offline-first experiences that truly offer a first-class offline experience for users. Though the challenge these apps face is their actual online capabilities are far more limited than their online-first counterparts. Evernote is an exemplar of the offline-first experience. You can usually count on Evernote to allow you to view, edit, and create notes offline. In fact, so much of the app's capabilities are seamlessly available when you are in an offline setting. However, as a user, you are made completely aware of the underlying sync architecture of the app, as Evernote presents a prominent sync icon in the app to show you the progress of the latest sync and to kick off a sync yourself. Inevitably if you've used Evernote for some time, you'll have run into their infamous sync conflicts which can happen after editing the same note on two different devices. Evernote's advice on how to avoid the issue? Asking the user to first wait for a complete sync to occur before viewing notes. At the same time, Evernote has always had a fairly limited set of online capabilities when it comes to sharing and collaborating on notes with others and fails to truly take advantage of what true connectivity can enable.
Defining our requirements
With this backdrop of the industry landscape and the understanding of the importance our users placed on offline functionality, we knew we wanted to create a far more compelling experience that wasn't riddled with the trade-offs we saw in most other apps.
We knew we wanted a rich offline experience that allowed you to seamlessly view, edit, and create notes offline, just as the offline-first experiences offered. Beyond basic note editing, we wanted to develop an architecture that enabled us to potentially enable each and every action that you can take in the app offline, like starring, moving, or archiving notes. We also knew we wanted users to be able to search across their notes while offline and not just offer the ability to browse through their various notebooks. At the same time, we wanted to make zero compromises on the incredible connected functionality that users have come to love about Notejoy when they are online, whether it's our real-time collaborative editing, user presence, live Chatter reactions and comments, real-time social notifications, and more. One of our core product design principles is "Productivity, meet collaboration." It's this idea that traditionally the tools that make us highly productive as individuals are completely different from the tools that enable us to seamlessly collaborate with others (as illustrated by the stark differences in Evernote and Google Docs mentioned earlier). We wanted to bring productivity and collaboration together in a single solution that didn't have the traditional trade-offs of today's disparate tools. We knew as we embarked on offline support, we needed to stay true to this principle.
We also knew we had a huge opportunity to design offline support in such a way to meaningfully improve the performance of Notejoy by taking advantage of the fact that your notes would now be locally synced to your device. Our users have never complained about the performance of Notejoy, but we knew speed was one of the most important ways of reducing friction and increasing productivity across the board. And enabling our users to get their most important ideas captured in notes even faster was so critical to what Notejoy was all about. So a core requirement would be designing offline support such that it delivered significant performance gains in both online and offline settings.
We also wanted to design offline support so that it would ultimately be available across all our interfaces. It would have been easy just to make our Mac and Windows apps support offline. But we instead decided that we wanted each of our supported browsers (Chrome, Safari, Firefox, Edge) to also support offline as the majority of our users relied on our web interface and we especially wanted the performance gains to be enjoyed by everyone.
And finally, another one of our core product design principles is "Intuitive and powerful." It's the idea that the best product experiences require no manuals, are incredibly intuitive to a broad base of users, and yet still find ways to offer a powerful set of capabilities. We felt Evernote, by make sync something that the average user needed to think about it, failed this principle. We didn't want the user to generally have to worry about or wait around for syncs. Instead, users should be able to intuitively use Notejoy just as they always have and somehow get these powerful new offline capabilities in their experience.
Let's now dive into the architecture behind Notejoy's offline experience that enabled us to satisfy each and every one of our lofty goals for the design.
Accessing Notejoy offline
Since our desktop apps are built with Electron, they are just an embedded version of the Chromium browser loading our web app. This enabled us to leverage core browser technology to implement a unified offline experience across our web and desktop experiences. To speed time to market, we've held off on adding offline support to our mobile apps until a future release.
To allow users to access Notejoy while offline, we took advantage of service workers, a core browser technology available in all modern browsers. A service worker effectively acts as a proxy that sits between your web app, the browser, and the network. Once setup, a service worker can intercept every network request made from your web app and decide how to handle it. We set it up so that when you were online, you would continue to download the network resource directly from the network. When you did so, we cached a subset of the network resources needed to render the Notejoy web app in cache storage, another modern browser capability that enables you to store any network response in the browser for later retrieval. Now when you attempted to load Notejoy and you were offline, our service worker would detect that and instead serve the requested resources directly out of cache storage. In doing so, users could continue to navigate to notejoy.com in their web browser even if they didn't have Internet access because our service worker would fetch all the required HTML, JS, CSS, and images needed to render the app from cache storage. You can also configure your service worker to pre-fetch a list of resources so even if the user hasn't accessed them, they are available in cache storage for use when offline. You do need to be careful about maintaining this list of resources to cache as your app evolves and you add or remove required resources. We automatically update this list of resources with every build to ensure our latest bundled JS and CSS files are included.
Detecting when you are offline
While it might seem like a trivial problem to detect when your app doesn't currently have Internet access, it actually turns out not to be. The browser provided navigator.onLine flag notoriously returns false positives when you in-fact don't have a viable Internet connection. So you need to invest in your own mechanisms for detecting connectivity.
Our primary mechanism relies on catching any failed api calls made by our web app to Notejoy's servers and passing them to a handleError function. This function checks whether you have a valid Internet connection to Notejoy's servers. It does this by making a ping api call to Notejoy's servers. To make this efficient, the ping api call is handled directly by our nginx front-end web server instead of being routed to our python back-end which would add unnecessary overhead. Notejoy relies on both http requests as well as web sockets to enable real-time editing, conversations, and notifications. So we also developed a ping web socket request that handleError can call to verify if it has a live web socket connection as well. Both the http and web socket requests have a fixed timeout and if either of them fail, we decide that Notejoy is currently offline and begin our offline sequence. By the way, AbortController has been the most efficient way to introduce a timeout into the standard browser fetch command and is broadly supported by modern browsers.
The offline sequence periodically makes the http and web socket ping api calls to determine whether you now have a viable connection. When both of these api calls return successfully, the app is once again marked as online. This retry mechanism uses exponential back-off with a fixed upper bound to avoid generating needless traffic and overloading Notejoy's servers while at the same time ensuring we get the user back online in a reasonable amount of time.
Indicating you are offline
When we thought about how we wanted to communicate to the user that they were currently offline, we noticed that most apps used a fairly heavy-weight UI treatment for notifying their users. Since we wanted to create a fairly seamless experience that allowed the user to continue uninterrupted with their work, we thought a far more subtle offline notification would be appropriate, as it would communicate to our users that they could go on their business without worrying about being offline.
So we designed the following offline indicator that shows up in the bottom left of the app. It subtly tells you that you are currently offline without getting in the way of any of your work in the app. If you happen to miss it, it's not a big deal because nothing fundamentally changes about your core experience. You can continue to view, edit, and create notes just as you would when you are online. If there is some action that is not available while you are offline and you attempt it, you'll receive a separate notification telling you so.
Storing offline data
While Electron apps offer a variety of unique data storage mechanisms, we knew we wanted to take advantage of core browser technology for storage to ensure our offline implementation was the same across our web and desktop experiences. Modern web browsers offer a variety of mechanisms for storing data offline, including local storage, Web SQL, IndexedDB, and cache storage.
As we mentioned earlier, we are using cache storage to store Notejoy's core HTML, JS, CSS, and images needed to render the web app in your browser when offline. We also leverage cache storage to store user-uploaded images & documents for offline access. When it comes to simply storing and retrieving a raw file, cache storage is the best solution.
Our app is a pretty standard React & Redux SPA web app, so we relied heavily on Redux for managing our in-memory state. One of the most popular libraries for redux is redux-persist, which allows you to easily persist your entire redux state to IndexedDB or alternative offline storage mechanisms and then easily re-hydrate your state when your app starts again. This greatly simplifies the efforts of developers as they only really need to worry about manipulating their in-memory state and they can rely on redux-persist to ensure that data is properly persisted. While convenient, we ultimately decided against taking advantage of redux-persist and similar libraries due to performance and memory concerns. Redux-persist works great when your overall memory footprint is fairly limited. But when you are potentially storing thousands of notes offline, loading all of those when the app starts into memory not only wastes countless memory, but also significantly slows down your app load. We instead opted to manage both our in-memory state and persisted IndexedDB data independently to ensure a more minimal memory footprint and far superior app load performance.
Viewing and editing notes
Now that we've covered the core infrastructure powering Notejoy's offline experience, we can now delve into the details of how Notejoy enables you to view, edit, and create notes, both offline as well as online to achieve the performance improvements we mentioned earlier we were looking for across Notejoy's overall experience.
To achieve maximum performance for our most popular action in Notejoy, viewing notes, we leveraged all three potential locations a note could be: in-memory in Redux state, stored in IndexedDB, or available via API from Notejoy's servers. In our initial implementation, when you clicked on a note in Notejoy, we first checked if the note was already in-memory in our state. If it was, we immediately rendered it. We then checked whether the note was stored in IndexedDB and load it if it was. And finally, we make an API call to get the note from Notejoy's servers. Whenever we receive a note from our API, we also persisted that note to IndexedDB. While theoretically this was a great approach, we ultimately found IndexedDB's performance to be fairly unpredictable, especially in our top browser, Google Chrome. It would mostly return queries in tens of milliseconds. But at times it could easily be hundreds of milliseconds. And since we used highly performant web sockets for our view note API requests, this would occasionally result in our API calls being faster than our local IndexedDB! Given maximizing performance was one of our most important goals, we decided to modify our algorithm. Now instead of first checking IndexedDB and then our API, we would make asynchronous calls to both simultaneously and then take advantage of whichever returned first.
This gets more complicated as we start to introduce edits into the mix. Let's start with the simple case of edits made by someone else that are stored in Notejoy's servers. Along with storing a note's content, we also store a monotonically increasing version number that increments with each note edit. Now instead of simply checking whether the note exists in-memory, in IndexedDB, or when we make an API call, we also compare the version number. So when you click on a note, we first attempt to load the note from our Redux state, if it exists there. We then make simultaneous calls to IndexedDB and our API. When each of those requests returns, we compare its version number to the version number we currently have loaded and if the version number is higher, we replace our current version with the newer one.
The next complication is to introduce local edits into the mix, as we may have made local offline edits that are still pending in terms of being persisted to the server. As we load a note from in-memory and IndexedDB, we also load any pending changes that have been made. Now if we encounter pending changes, we load them immediately into the editor. Now if IndexedDB or our API has a newer note version, we can't immediately load them into the editor as we need to first push our current changes to the server. So we simply add a flag to the note saying that a newer version is available and we should reload after we've pushed our local changes to the server.
Each of the above scenarios contributes to a variety of possible situations we need to account for. The below diagram models each of the potential cases we can find ourselves in that we need to ensure we handle appropriately.
Keep in mind, there are so many simplifications you can make to the view note algorithm to avoid all of this complexity. For example, you could simply always load the note from the API when you are online and avoid worrying about in-memory and IndexedDB state. Or you could only make API calls after you have retrieved note data from IndexedDB. But with our primary goal of maximizing the performance of Notejoy for both online and offline users, we implemented this more sophisticated approach to take advantage of every performance optimization we could. And boy was it worth it. I can safely say the number one benefit of Notejoy's offline support is that Notejoy is significantly faster when browsing through notes, both offline and on.
When you make an edit to a note offline, we immediately persist that edit in both our in-memory Redux state but also to IndexedDB. We have a periodic sendNoteChanges timer that continually checks whether the user is currently online and if they are, sends any pending in-memory note changes. These note changes are sent via web sockets. When you first load the app, we also load any pending changes from IndexedDB into our in-memory state and begin sending them whenever we have a connection.
Most other note actions are sent via http api calls. We have a separate mechanism that automatically queues http requests that fail or are made when offline, and replays them to the server in their original order when you come back online. There are two nuances to this implementation. The first is, you don't necessarily want all api calls that fail when offline to be replayed to the server. For example, a lot of GET requests are no longer relevant so you don't want to waste unnecessary requests. In addition, since the client could end up sending the same API request to the server twice because a previous attempt timed out, we must ensure that all our API requests are idempotent. For these reasons, we made it so each API request manually opts into our offline mechanism.
One of the most important aspects we wanted to ensure in our experience was conflict-free sync resolution. We didn't want to end up in the world of pain that many Evernote users found themselves in when making changes to their notes across multiple devices and having to worry whether a sync was complete before safely editing their notes. The great thing is we had largely already solved this problem when we implemented collaborative editing leveraging operational transformation. This gave us a mechanism to ensure any edit made could easily merge with any other edit, regardless of the version of the note they were originally edited against. We enabled this mechanism for real-time editing by keeping a history of all recent note edits in server memory using Redis so that we could transform edits against each other to apply them to the latest version of the note. To make this work in the offline case, we needed the history of edits to persist beyond just a real-time editing session. So we also committed the full history of note edits to our server database, so we could appropriately transform edits between versions regardless of when you made them. We still leveraged the Redis note history first to maintain performance of our real-time collaborative editing scenarios, but introduced the database note history as a fallback in case the needed history was no longer available in memory.
Creating a great offline search experience was also important to us, as we wanted to ensure users could quickly navigate to the note they were looking for without having to browse through their libraries and notebooks. We realized that we already had the perfect local search experience built in the form of Quick Find, which allowed you to quickly search and jump to a note. We enabled this by downloading an offline index of all notes you had access to, including both your personal notes and notes shared with you. We realized the same mechanism would be a great default search experience when offline. We just needed to ensure we created a periodic mechanism for updating the index whenever you were online to ensure you had the latest index to search against.
The final piece was creating a sync mechanism that proactively downloaded any recently modified notes that weren't already stored locally. We created a web worker to accomplish this, which is another core browser technology that enables you to run scripts in a background thread that helps to ensure no performance degradation of your main app thread. We were initially intrigued by shared workers, as they would allow you to create one single background thread across multiple browser tabs, but abandoned it when we realized Apple Safari doesn't support them. Browser extensions were another common way to communicate across browser tabs, but we didn't want to introduce the friction of requiring a browser plugin just to use Notejoy offline. So we were stuck with web workers. The main challenge was that web workers have no knowledge of the other tabs the user has open of Notejoy. This is problematic because we didn't want a user to have 3 Notejoy browser tabs open and three separate syncs happening. To resolve this, we needed our own mechanism of communicating between browser tabs. We leveraged the broadcast-channel library which enables you to communicate with any other browser tab running your application. We used this mechanism to ensure there was only ever one sync web worker running. If the tab that was currently running the sync worker closed, it would nominate another tab to take over the syncing responsibility.
The simplest implementation of a sync engine simply stores a timestamp of the last sync time and then sends that timestamp to the server and the server returns every note that has been modified since that timestamp. While this approach certainly works, it didn't take advantage of the fact that we were constantly syncing notes as they were viewed in Notejoy. And this simple sync algorithm would result in tons of unnecessary notes being sent to the client that the client was already up-to-date on. Instead, we opted for an approach where the server sends the modified timestamp of every note the user has access to the client and the client then syncs any notes it doesn't already have or whose modified date is greater than the version it already has. It turns out we could generate the list of note modified timestamps efficiently without ever hitting our production database because we already leveraged Elasticsearch for our search infrastructure and it had all the information the client needed. Our web worker would then periodically download the notes index, compare each timestamp to the notes stored locally, generate a list of notes needing updating, and begin downloading them from the server. After downloading a note's content, it would also download any attached images and documents to cache storage for offline viewing. Since images and documents were stored on S3, this created limited additional load on our own servers. To help maintain the performance of Notejoy's servers, before a client begins a sync, it gets sync configuration information from the server to dictate how frequently it is allowed to sync and how many notes it can sync per batch.
Users can go to our Offline sync settings page to see the status of their sync, enable or disable sync, or clear local storage to free up space. But they never actually have to do that. We don't put a sync button front and center in the app, because it all happens seamlessly behind the scenes. Since anytime you view a note, we are also downloading it to your device, you know that any notes you've recently viewed are already synced. The background sync is always running and downloading any notes that have been modified to keep your local device up-to-date. But even if you end up in a situation where you are editing an old version of a note, our operational transformation conflict-free sync resolution mechanism ensures those edits get applied appropriately to the note anyway. We've thus accomplished our goal of an intuitive but powerful offline experience.
Try it yourself
I hope this gave you a detailed look at how we set out to build an industry-leading offline experience in Notejoy and how we were able to architect our system to actually achieve it. I'd also encourage you to see it all in action yourself by giving Notejoy a try.
Enjoyed this essay?
Get my monthly essays on product management & entrepreneurship delivered to your inbox.
Jun 15, 2020