Invoice2go released its first mobile application over a decade ago. Back then, mobile data plans weren’t what they are today. Plans were less generous. They were also slower, more constrained and more expensive, so the app had to work well offline. Then in 2017, when we were rebuilding the app, we took the opportunity to consider whether an offline-first experience was still the right thing for our users — and for ourselves.
Why does it matter?
Being offline isn’t just about whether or not the user has access to a network. Even today, there are remote places and less developed areas where the connection between a smartphone and a network can’t be guaranteed.
Relying completely on the device’s connectivity may lead to problems, caused by slow responses or network failures. In those scenarios, the user’s left with only two options: retry, or give up. You can imagine how frustrating this scenario might be for users out in the field, trying to create an invoice.
Building an online solution was the right thing to do, but we had to be able to guarantee that users could still do what they needed to do, regardless of their internet connectivity.
Challenges
To offer offline capabilities, we needed to build and maintain a specific architecture, in conjunction with the development of new features, and the overall evolution of the product. Consequently, we needed to add more business logic to the app, and the source of truth became a little blurry.
In this article, I want to share 3 challenges we faced while making these changes to Invoice2go:
Data availability
Data synchronization
Conflict resolution
Defining our offline strategy
The first step was to define what kind of offline experience we wanted to give our users. It could’ve been as simple as only letting them read information, or it could’ve been more robust, and given them full control of their data while they were offline.
Simple cache
A good starting point was to cache API responses, which would reduce loading states and network consumption. When using this approach, there are few things you need to consider, such as the size of the cache and time-to-live (TTL).
Support mutation
In order to give users more flexibility, we needed to support creation and mutation of their data while they were offline. This would require a well-defined data structure, a way to ensure that user changes persisted, and potentially a mechanism to resolve data synchronization conflicts.
Save intent
We wanted the offline experience provided to users to be transparent. They should only notice minimal differences while using the app offline. To reach this level of trust, any changes needed to persist, with a promise that they’d be synchronized as soon as possible. Even actions that require the user to be online could be recorded as something to perform when the user had a stable network connection. The offline limitations of the app would only be apparent to users who use Invoice2go across multiple devices.
At Invoice2go we fully support mutations, and we save most of the intents. Therefore, for the core usage of the app, offline users are allowed to create multiple entities from their device, such as invoices, clients, time tracking and expenses. There are some features that aren’t supported offline, for example our global search (searching across the whole app, across all data types). That relies on elastic search.
Data availability
Mobile applications that consist of rendering JSON nicely with only few PUT/POST requests can be architected with fewer building blocks:
A view layer, to display information and listen to user interactions
A business logic layer to process, transform and interpret requests for / from the user
A network layer to transport / request / send data
Database
To make data available at any time and anywhere, we needed to introduce a way to store data so that it could be manipulated directly from the device. There are many choices of database we could’ve used, and I won’t go through them here. We now use Realm, to replaceme SQLite which we used in the past.
Scheduling
Another major building block we needed to build and use in our architecture was a job manager. Its responsibility is to queue, prioritize, group tasks, and keep them persistent in the background. The job manager schedules network requests to keep our database up-to-date (which I’ll explain more when I talk about data synchronization) and to inform the back-end about changes.
Source of truth
If we think of the source of truth as whatever the user sees on their device, the source of truth for a mobile app is the data layer. This principle makes it very important to keep local/remote databases synchronized, but it also simplifies the development experience and the architecture.
When users are reading data, we fetch from the local database and eventually schedule a request to ensure we have synchronized data.
When users are writing data, we save locally and schedule a request to let the back-end know.
This has been generalized by introducing models that are structured following a repository design pattern. In addition to being responsible for accessing data, it’s also interacting with the job manager when necessary.
Data synchronization
As well as storing data locally, we needed to identify when it was appropriate to synchronize with the back-end. The goal was to share the same data between multiple devices, and at least to provide a back-up.
Proactive: Before anything
After the app launches there’s an opportunity to schedule a get to the most used features, so that we can anticipate and prefetch data before the user needs it. That’s also a good time to resume the job manager, so it can continue to execute jobs that were previously queued.
Background: When it’s not crucial
From a screen where we display a list of objects, it should be safe to display what we have locally first, and then in the background schedule a refresh and verify that we have the latest information locally. In most of the cases, the user won’t notice anything, and the whole thing feels very responsive.
Another use case is when the user actually saves some changes. With offline capabilities, the user doesn’t need to wait for a successful synchronization with the back-end. The user can keep doing what they’re doing, while in the background, the job manager schedules a request to synchronize changes.
Reactive: When we don’t have it locally
Unfortunately, sometimes we have to be reactive and we have to fallback to a state where we ask the user to wait for information. We try to avoid this as much as possible by being proactive, or requesting in the background when needed.
Conflicts resolutions
Before trying to solve this challenge, we made an assumption that we wouldn’t have multiple Invoice2go users trying to edit the exact same object at the same time. To keep things simple and to avoid over-engineering it, we just followed the rule that the last write wins.
When a user makes a change to an object, we mark it with a boolean that indicates that it’s dirty, or pending synchronization
When we receive read requests, we usually replace our local values, but we have to filter out dirty objects so as not to overwrite and lose changes that haven’t been synchronized to the back-end.
Conclusion
Having an offline-first experience comes with tradeoffs. The architecture of mobile applications becomes more complex to allow new capabilities: It requires a way to persist data locally, a way to schedule requests and a solution to solve conflicts.
If you’re heading down this path, you need to evaluate how far you need to support your offline experience, whether it’s relevant (or the right thing to do), and whether or not it brings genuine value to your users, or to your business. For us, the benefits were considerable — we gave our users freedom and ease of use, high responsiveness and no more delays. These things aren’t always easy to measure, but they feel good.