Opening
Here is a question for you. How often do your users have no network? If you said "rarely," I have some bad news. Users are on airplanes, in subway tunnels, in basements, in rural areas, in countries with unreliable infrastructure. They are walking between Wi-Fi and cellular, hitting dead zones in buildings, competing for bandwidth at concerts and conferences. The truth is, your users have no network — or bad network — far more often than you think. And when that happens, what does your app do? If the answer is "show an error screen and become useless," then you have a design problem, not a network problem. Today we are going to talk about offline-first architecture — the idea that your database is the source of truth and the network is just a sync mechanism. This is one of the most impactful architectural patterns for mobile, and it changes how you think about data flow in a fundamental way.
The Online-First Problem
Most apps are built online-first. The user requests data, the app fetches it from the server, and displays it. If the server is unreachable, the app shows an error. This feels natural because it mirrors how we think about data — the server has the real data, and the app just displays it. But on mobile, this breaks down constantly. The user opens your app on the subway. No network. They see a loading spinner, then an error. They try again. Same thing. They close the app in frustration. Even with a network, the online-first approach has problems. Every screen transition requires a network call. That means latency. The user taps something and waits 500 milliseconds to a second for content to appear. They navigate back and forward — another wait. The app feels sluggish even when the network is fine. And if the user makes changes — editing a profile, writing a message, toggling a setting — and the network drops mid-operation, what happens to those changes? In an online-first app, they are usually lost. This is a terrible experience.
The Offline-First Mindset
Offline-first flips the model. Instead of server equals truth and app equals viewer, the pattern is: local database equals truth and server equals sync target. When the user opens a screen, the app loads data from the local Room database immediately. No network call needed. The data is right there on disk. It appears instantly. In the background, the app also kicks off a network request to fetch the latest data from the server. When that response arrives, it updates the Room database. Because your UI is observing the database through a Flow, it automatically updates when new data arrives. The user sees immediate content, then a seamless refresh if anything has changed. If there is no network? The user still sees data — maybe slightly stale, but real, useful data. They can still interact with the app. Changes are saved locally and synced when connectivity returns. This is a fundamentally better experience. Instant data, graceful degradation, no error screens for temporary network issues.
The Architecture: Room + Flow + Retrofit
Let me show you the concrete architecture. You have three layers. Room is your local database — the single source of truth. Retrofit is your network layer — it talks to the server. And a repository sits between them, coordinating the flow. Your ViewModel observes a Flow from the repository. The repository returns a Flow from Room — something like userDao.getUser(id). This Flow emits immediately with whatever is in the database, and emits again whenever the underlying data changes. The repository also has a refresh function that calls Retrofit to fetch fresh data from the server. When the response arrives, the repository writes it into Room. This triggers the Flow to emit again, which triggers the ViewModel to update, which triggers the UI to refresh. The key insight is that the network response never goes directly to the UI. It always goes through Room first. Room is the single source of truth. The network is just one way that Room gets updated. This pattern has a name in the Android community — the single source of truth pattern — and it is the foundation of offline-first architecture.
Optimistic UI Updates
Now here is where offline-first gets really interesting: optimistic UI updates. When the user performs an action — say, favoriting a post — the app does not wait for the server to confirm it. Instead, it immediately writes the change to the Room database. The UI updates instantly because it is observing Room. The user sees the heart fill in with zero latency. Meanwhile, in the background, the app sends the favorite request to the server. If it succeeds, great — the local state and server state are already in sync. If it fails — maybe the network is down — the change stays in Room, marked as "pending sync." The user does not see an error. They continue using the app normally. When the network comes back, the app retries the sync. The result is an app that feels incredibly fast and responsive. Taps have immediate visual feedback. There is no spinner, no waiting, no "please wait while we save." The tradeoff is complexity — because now you have to handle the case where the server rejects the optimistic update. But for most interactions, this tradeoff is absolutely worth it.
Sync Strategies: Push, Pull, and Hybrid
Once you commit to offline-first, you need a sync strategy. There are three main approaches. Pull-based sync is the simplest. The app periodically fetches data from the server — on launch, on pull-to-refresh, or on a timer. It is easy to implement but wastes bandwidth when nothing has changed, and data can be stale between pulls. Push-based sync uses something like Firebase Cloud Messaging or WebSockets to notify the app when data changes on the server. The app then fetches the updated data. This is more efficient — you only fetch when something has actually changed — but it requires server-side infrastructure and a persistent connection or push channel. Hybrid is what most production apps use. You do a pull on launch to get the latest state, set up a push channel for real-time updates, and fall back to periodic polling if the push channel disconnects. This gives you freshness, efficiency, and resilience. The choice depends on your data. Chat messages? Push is essential. Settings or profile data? Pull on launch is fine. A news feed? Hybrid — pull on launch, push for breaking news.
WorkManager for Deferred Sync
When the user makes a change offline, you need to sync it when the network returns. This is where WorkManager comes in. WorkManager is designed for exactly this scenario — deferred, guaranteed work that must eventually complete, even if the app is killed or the device restarts. When the user favorites a post offline, you enqueue a WorkManager task that sends the favorite to the server. You set a network constraint — the work should only run when the network is available. WorkManager handles the rest. It persists the work request to a database. It monitors network state. When connectivity returns, it executes your worker. If it fails, WorkManager retries with configurable backoff. The work survives process death. It survives device reboots. This is critical because users do not think about sync. They favorite a post on the subway, close the app, and expect that when they open it on Wi-Fi an hour later, everything just works. WorkManager makes that happen. You should use OneTimeWorkRequest for individual sync operations and set appropriate retry policies. For batching multiple pending changes, you can use ExistingWorkPolicy.APPEND to chain them.
The Hard Part: Conflict Resolution
Everything we have covered so far is the easy part of offline-first. The hard part is conflict resolution. What happens when the user edits something offline, and another user — or the same user on another device — edits the same thing on the server? Now you have two versions of the truth, and they disagree. Let me give you a concrete example. User A edits a document title to "Project Alpha" on their phone while offline. Meanwhile, User B edits the same title to "Project Beta" on the web. When User A's phone comes back online and tries to sync, which title wins? There is no universally correct answer. Different strategies work for different situations. But you have to pick one, and you have to implement it. Ignoring conflicts — which is what many apps do — means data loss. Whichever write hits the server last silently overwrites the other.
Conflict Resolution Strategies
Let me walk you through the most common approaches. Last-write-wins is the simplest. Each write gets a timestamp, and the most recent one wins. It is easy to implement but it can lose data silently. If User A's offline edit has an older timestamp than User B's online edit, User A's changes disappear without warning. For many types of data — settings, preferences, simple state — this is acceptable. But for user-generated content, it feels terrible. Server-wins is a variation — the server always has the authoritative version, and local changes are discarded on conflict. This is safe but frustrating. Client-wins is the opposite — local changes always override the server. This is good for user-centric data but bad for shared data. Merge is the most sophisticated approach. You take both versions and combine them. For text, this could mean a three-way merge like Git does with source code. For structured data, you might merge at the field level — keep the server's title but the client's description. For lists, you might union the items. Merging is powerful but complex, and it can produce results that neither user intended. The pragmatic approach for most apps is field-level last-write-wins with conflict detection. Track which fields changed and when. If the same field was changed on both sides, take the latest. If different fields changed, merge them. And if there is a conflict the algorithm cannot resolve, show it to the user and let them choose.
Implementing Offline-First in Practice
Let me give you a practical implementation pattern. Every entity in your Room database gets three metadata fields: a last-modified timestamp, a sync status enum — synced, pending, or conflict — and optionally a server version or ETag. When the user makes a change, you set sync status to pending and update the timestamp. WorkManager picks up pending items and sends them to the server. The server's API should support conditional updates — "update this only if the version I have matches" — using ETags or version numbers. If the update succeeds, set sync status to synced and update the version. If the server returns a 409 Conflict, you fetch the server version, run your merge logic, and either resolve automatically or set the status to conflict and notify the user. Your UI observes the sync status. For pending items, you might show a small sync icon. For conflicts, you show a resolution UI. This keeps the user informed without blocking their workflow.
What Not to Store Offline
A quick word of caution: offline-first does not mean storing everything locally. Some data should not be cached. Sensitive data like authentication tokens need careful handling — encrypted storage, not plain Room. Data that must be real-time — like stock prices, live scores, or auction bids — should not show stale values, because stale data here is worse than no data. Very large datasets should be cached selectively — keep the most recent items, not the entire history. And data with complex access control should be re-validated when coming back online — the user's permissions might have changed. The general rule: cache data that the user created or frequently reads, expire data that changes rapidly, and protect data that is sensitive.
Recap and What is Next
Let us wrap up this phase on networking and IO. Offline-first means the local database is the source of truth, and the network is a sync mechanism. The Room plus Flow plus Retrofit pattern gives you immediate data display with background refresh. Optimistic UI updates make the app feel fast by writing locally first and syncing later. WorkManager handles deferred sync with guaranteed execution. Conflict resolution — the hard part — requires choosing between last-write-wins, server-wins, merge, or user choice. Every app on mobile needs to handle the no-network case gracefully. Not as an error, but as a normal state of operation. Throughout this phase, we have followed data from a Retrofit call through DNS, TCP, TLS, HTTP, serialization, and all the way to local storage. You now understand the full lifecycle of a network request on Android — not just what happens, but why each step takes the time it does and what you can do about it. In the next phase, we are going to shift to a completely different topic — concurrency. How Android manages multiple things happening at the same time, what coroutines actually do under the hood, and why threading is the source of the most subtle bugs in mobile development. See you there.