Though offline support is becoming more of a feature with rich web applications, it has always been a core use case with native apps. People expect native apps to be usable when connectivity drops. And they certainly expect state to not get lost, when the signal drops or gets weaker for a short period of time.
Proper offline mode support adds a lot of complexity and interesting edge cases to an app. State needs to be persisted on the filesystem, using built-in storage options, or a storage framework. When connection recovers, state needs to be synchronized. You need to account for race conditions when a user uses the app on multiple devices - some online, one offline. You should take additional care with app updates that modify the locally stored data, migrating the “old” data to the “new” format. We’ll cover this case in more detail in Part 3 of the series.
Decide what features should work offline, and which ones should not. Many teams miss this simple step that makes planning of the offline functionality easier, and avoids scope creep. I suggest starting with the key parts of the application, and expand this scope slowly. Get real-world feedback that the “main” offline mode works as expected. Can you leverage your approach in other parts of the app?
Decide how to handle offline edge cases. What do you want to do with extremely slow connections: where the phone is still online, but data connection is overly slow? A robust solution is to treat this as offline, and perhaps notify the user of this fact. What about timeouts? Will you retry?
Retries can be a tricky edge case. Say you have a connection that has not responded for some time - a soft timeout - and you retry another request. You might see race conditions or data issues if the first request returns, then the second request does so as well.
Synchronization of device and backend data is another common, yet surprisingly challenging problem. This problem multiplied with multiple devices. You need to choose a conflict resolution protocol that works well enough for multiple parallel, offline edits, and is robust enough to handle connectivity dropping midway.
With poor connectivity, the network request can sometimes time out. Sensible retry strategies, or moving over to offline mode could be helpful. Both solutions come with plenty of tradeoffs to think about.
Retry strategies come with edge cases you need to think about. Before retrying, how can you be sure that the network is not down? How do you handle users frantically retrying - and possibly creating multiple parallel requests? Will the app allow the same request to be made, while the previous one has not completed? With a switch to offline mode, how can the app tell when the network has reliably recovered? How can the app differentiate between the backend service not responding, versus the network being slow? What about resource efficiency - should you look into using HTTP conditional requests with retries utilizing ETags or if-match headers?
Much of the above situations can be solved relatively simply when using reactive libraries to handle network connections - the likes of RxSwift, Apple’s Combine or RxJava. There is an edge case that goes beyond the client side, that does get tricky: retries that should not be blindly retried.
Requests that should not be retried come with a separate set of problems. For example, you might not want to retry a payment request, while it’s in progress. But what if it comes back as failed? You might think it’s safe to do so. However, what if the request timed out, but the server made the payment? You’ll double charge the user.
As a consumer of backend endpoints, you should push all retries on API endpoints to be safe by having these endpoints be idempotent. With idempotent endpoints, you’ll have to obtain and send over idempotency keys and keep track of an additional state. You’ll also have to worry about edge cases like the app crashing and restarting, and the idempotency key not being persisted. Implementing retries safely can add a lot of mental overhead for teams: and you’ll have to work closely with the backend team in mapping the use cases and edge cases you need to design for.
As with state management, the key for a maintainable offline mode and weak connection support is simplicity. Use immutable states, straightforward sync strategies and simple strategies to handle slow connections. And do plenty of testing with the right tools such as the Network Link Conditioner for iOS or the networkSpeed capability on Android emulators.
Building Mobile Apps at Scale
"An essential read for anyone working with mobile apps. Not just for mobile engineers - but also on the backend or web teams. The book is full of insights coming from someone who has done engineering at scale."
- Ruj Sabya, formerly Sr Engineering Manager @ Flipkart