Offline App Layered Architecture

Building Offline Apps: A Fullstack Approach to Mobile Resilience

Think-it logo
Mobile Chapter
Engineering.24 min read
A step-by-step guide to building offline mobile apps

Introduction

In many parts of the world, connectivity isn't a given. From rural farms and forested conservation zones to construction sites in remote areas, mobile users often work in low or no-internet environments. In these settings, the traditional mobile app model—where everything depends on being online—falls apart.

Offline-first design flips the script: it makes local data the source of truth, syncs with the backend opportunistically, and ensures the app stays functional and trustworthy no matter the network status.

In this guide, you'll learn:

  • When and why to design offline-first apps
  • How to structure your Android architecture using Room, WorkManager, Hilt, and Repository pattern
  • Backend strategies for syncing, conflict resolution, and batching

Understanding how to architect a truly resilient mobile app is critical.

What is an Offline-First App?

An offline-first app is designed to perform all or a critical subset of its core functionalities without internet connectivity. This means it can execute its business logic offline, ensuring usability even when network access is unreliable or unavailable.

Importance of Offline-First App Design

The offline-first design is crucial for several reasons"

  • Network Reliability: Devices often experience spotty or slow network connections, such as limited bandwidth or transitory interruptions (e.g., elevators, tunnels).
  • User Experience: Apps should remain usable without a reliable network connection, presenting local data immediately rather than waiting for network calls.
  • Resource Efficiency: Fetching data should be conscious of battery and data usage, optimizing requests for when the device is charging or on WiFi.

Why Offline-First? When to Use It

Offline-first is not just a feature—it’s a concept. It’s considered when your users:

  • Work in forests, farms, or rural zones (e.g., tree inspections, soil monitoring)
  • Operate in construction sites with unstable networks
  • Need guaranteed app functionality regardless of signal

With offline-first:

  • The app remains fully usable offline
  • All writes are saved locally first
  • Syncs run automatically in the background

When You Should Consider Offline-First Design

Here are some common domains that need offline-first apps:

  • Forestry & Environmental Conservation: Field workers operate deep in forested areas with no reception. Apps that monitor biodiversity, trees, or land degradation must function fully offline.
  • Farming and AgriTech: Remote farms with weak infrastructure need apps that can record soil data, crop cycles, and livestock information without waiting for a signal.
  • Construction & Surveying: On-site workers must record measurements, safety reports, and GPS data in areas where cellular service is unavailable.
  • Rural Healthcare & Education: Community health workers or mobile teachers require uninterrupted access to patient records or learning content.

Itʼs Not Just a Feature—Itʼs a Design Philosophy

Designing for offline-first means thinking beyond connectivity:

  • Can the user complete tasks without being online?
  • How will you queue and retry their actions?
  • How do you sync and reconcile data safely?

Let’s dive into the architecture of an offline-first app.

Offline-first app solution

See how we solved for emergency response in low-connectivity areas

Read the story

Best Practices for Solving Connectivity Issues in Mobile Apps

When building mobile apps for low connectivity areas, there are several best practices to create a good user experience even when network conditions are poor.

  • Use an Offline-First Approach: Design your app to function without an internet connection by utilizing local storage and caching. Implement efficient data synchronization strategies to sync data once connectivity is restored.
  • Optimize Images and Assets: Reduce the size of images and assets using compression and resizing techniques. Implement lazy loading to load assets only when needed, and adapt loading strategies based on network conditions.
  • Minimize Data Usage and Requests: Use efficient data formats like JSON or protobuf to reduce payload size. Employ techniques like batching, throttling, and debouncing to minimize network calls and adapt to network quality using APIs.
  • Provide Feedback and Guidance: Use indicators, messages, and notifications to inform users about network status and app processes. Employ placeholders and spinners to indicate loading states.

Test and Monitor Your App: Use tools like Chrome DevTools or Android Studio to simulate various network conditions. Implement analytics and crash reporting to monitor app performance and user behavior.

Architecture & Data Flow

When you design an offline-first Android app, you’re architecting a system that can survive in uncertainty. Your app needs to collect data, display it, update it, and sync it… without depending on a server being available at that exact moment.

Offline App Layered Architecture

Offline App Layered Architecture

Let’s break down what a offline-first architecture looks like:

  1. UI Layer (Jetpack Compose + ViewModel)
    • Reads from a single source of truth which the local storage (Room DB).
    • Doesn’t care whether data came from a remote API or local storage.
    • Reflects changes instantly using Flow or LiveData which are sort of observer pattern library allow live access to local storage
  2. Data Layer (Repository Pattern)
    • Provides a single entry point for data to the ViewModel
    • Abstracts data sources: Room and Retrofit (or any network client).
    • Handles queuing of writes, sync logic, and data flow management.
  3. Sync Layer (WorkManager)
    • Defers network operations like syncing and refreshing
    • Handles retries, constraints (e.g., Wi-Fi only), and error recovery

An offline-first app is designed to perform essential functions without internet access, ensuring resilience and effective data handling during connectivity interruptions. The architecture focuses on the data layer, which is responsible for managing application data and business logic.

Data Layer Design

The data layer in offline-first apps consists of two main data sources:

  • Local Data Source: Acts as the canonical source of truth, ensuring data consistency across connection states. It is typically backed by persistent storage, such as relational databases (e.g., Room), unstructured data sources, or simple files.
  • Network Data Source: Represents the actual state of the application, which the local data source synchronizes with when connectivity is restored.

Data Operations

Offline-first apps must handle data reads and writes effectively:

  • Reads: Should be performed directly from the local data source using observable types to ensure the app can react to data changes when connectivity is restored.
  • Writes: Can be handled using asynchronous APIs to avoid blocking the UI thread. Strategies include online-only writes, queued writes, and lazy writes, depending on data criticality and time sensitivity.

Synchronization and Conflict Resolution

Upon restoring connectivity, synchronization reconciles local and network data sources. Strategies include:

  • Pull-based Synchronization: Fetches data on demand, suitable for brief connectivity interruptions.
  • Push-based Synchronization: Relies on server notifications to update local data, allowing for extended offline periods.

Conflict resolution often uses a 'last write wins' strategy, where the most recent data update is prioritized.

WorkManager Utilization

WorkManager is essential for managing persistent work, such as queuing read and write operations and monitoring network connectivity to trigger synchronization tasks.

Data Flow

Here’s what happens under the hood when a user performs an action offline:

Write Flow

  1. User performs an action, like reporting a fallen tree.
  2. The app writes this report to Room, marking it as isSynced = false.
  3. The UI is instantly updated, since it observes Room via LiveData or Flow.
  4. WorkManager is triggered and scheduled
  5. Once a connection is available, WorkManager syncs the data to the backend
  6. On successful sync, Room is updated and isSynced = true

User sees immediate results. Sync happens in the background.

Read Flow

  1. UI observes Room as the single source of truth.
  2. WorkManager triggers a remote fetch (if online)
  3. Fresh data is fetched from the backend
  4. Remote data source saves it to Room
  5. Room notifies the UI automatically (thanks to Room + Flow or LiveData).

User always sees something, even without internet.

User eventually sees fresh data as soon as it’s available.

This Read-Through-Local / Write-To-Local strategy decouples the UI from the network. It's the backbone of an offline-first experience.

This flow ensures:

  • A responsive UI (instant feedback from Room).
  • A robust pipeline for data syncing and recovery.
  • Clear separation between offline logic and network logic.

Here’s a diagram of this dual flow:

Dual Flow Offline App Design

Dual Flow Offline App Design

/data
  /local (Room DAOs)
  /remote (Retrofit APIs)
  /repository
  /sync (WorkManager jobs)
/ui
  /screens (Compose)
  /viewmodels
/di (Hilt modules)
/model (shared data models)

Example Folder Structure

With this setup in place, your app becomes resilient—even without touching the backend yet.

Next, we’ll zoom into the Android-specific pieces: Room, Repository, WorkManager, and how Hilt ties them all together.

See a dual data flow in action

Read about how offline first app is solving for carbon removal in the tropics

Read the story

Core Components: Room, Repository, Retrofit, WorkManager, Hilt

Building offline-first apps becomes straightforward when you rely on Android’s core libraries like Room, Repository Pattern, WorkManager, and Hilt for dependency injection. These components work together within a Clean Architecture setup to ensure seamless user experiences even when offline.

Role of Each Component

  • Room: Acts as a local database to cache data. It allows the app to function without a network connection by storing data locally and synchronizing with the server when the connection is restored.
  • WorkManager: Manages background tasks that need to be executed reliably, such as syncing data with a remote server. It ensures that data changes made while offline are eventually synchronized when the device is back online.
  • Hilt: Simplifies dependency injection by providing a structured way to manage dependencies across the app. It reduces boilerplate code and integrates seamlessly with Android components, ensuring that Room and WorkManager can be easily injected and managed within the app's architecture.

Let's break that down further:

Room (Local Database)

Room is your local, SQLite-backed database that acts as the single source of truth. Whether you're displaying data, queuing offline writes, or syncing changes, Room plays a pivotal role in ensuring data consistency.

Key benefits:

  • Seamless integration with Flow or LiveData.
  • Support for relationships, type converters, and easy query building.
  • Automatic data updates when the database changes.
@Entity(tableName = "trees")
data class Tree(
    @PrimaryKey val id: String,
    val name: String,
    val status: String,
    val lastReported: Long,
    val isSynced: Boolean = false
)

@Dao
interface TreeDao {
    @Query("SELECT * FROM trees")
    fun observeAllTrees(): Flow<List<Tree>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(trees: List<Tree>)

    @Query("SELECT * FROM trees WHERE isSynced = 0")
    suspend fun getUnsyncedTrees(): List<Tree>

    @Query("UPDATE trees SET isSynced = 1 WHERE id = :id")
    suspend fun markAsSynced(id: String)
}

Repository (Sync & Data Logic)

The Repository abstracts the data sources and handles the syncing logic between Room and the remote API. The repository is responsible for reading from Room and updating it through workers.

Here’s an updated version of the TreeRepository that schedules the sync task using WorkManager:

@Singleton
class TreeRepository @Inject constructor(
    private val localDataSource: TreeDao,
    private val workManager: WorkManager
) {
    suspend fun getTrees(): Flow<List<Tree>> {
        scheduleFetchWorker()
        return localDataSource.observeAllTrees()
    }

    suspend fun reportTreeIssue(issue: TreeIssue) {
        localDataSource.insertIssue(issue.copy(isSynced = false))
        scheduleSyncWorker()
    }

    private fun scheduleFetchWorker() { /* enqueue TreeFetchWorker */ }
    private fun scheduleSyncWorker() { /* enqueue TreeSyncWorker */ }
}

Key Features:

  • getTrees(): Reads from Room and triggers a remote sync. If data is fetched remotely, Room is updated and ui is notified with observer pattern.
  • reportTreeIssue(): Writes issues to Room with isSynced = false and triggers a background sync via WorkManager.
  • scheduleFetchWorker(): Schedules the TreeFetchWorker to invalidate local data and fetch fresh data if network is available.
  • scheduleSyncWorker(): Schedules the TreeSyncWorker to upload unsynced issues when the network is available and update synced trees .

Retrofit (Remote API)

The Retrofit interface defines how we communicate with the backend for both fetching and syncing data.

class TreeRemoteDataSource @Inject constructor(
    private val api: TreeApi,
    private val localDataSource: TreeDao,
) {
    suspend fun fetchTrees() {
		    val response = api.fetchTrees()
		    if(response.isSuccessful()){
				    localDataSource.insertAll(response.body())
		    }
    }
	
		suspend fun syncUnsyncedTrees() {
				val unsyncedTrees = localDataSource.getUnsyncedTrees()
				val response = api.syncUnsyncedTrees(unsyncedTrees)
		    if(response.isSuccessful()){
				    /* extract response data and use localDataSource.markAsSynced */
		    }
		}
}
interface TreeApi {
    @GET("trees")
    suspend fun fetchTrees(): Respomse<List<Tree>>

    @POST("sync/trees")
    suspend fun syncUnsyncedTrees(@Body trees: List<Tree>): Response<Unit>
}

WorkManager (Background Syncing)

WorkManager handles deferred background tasks. It's ideal for managing tasks that need to occur periodically or when the app is idle, such as syncing offline data. WorkManager works well with constraints like only sync when online.

class TreeFetchWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted workerParams: WorkerParameters,
    private val remoteDataSource: TreeRemoteDataSource
) : CoroutineWorker(context, workerParams) {
    override suspend fun doWork(): Result {
        return try {
            remoteDataSource.fetchTrees()
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
}
class TreeSyncWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted workerParams: WorkerParameters,
    private val remoteDataSource: TreeRemoteDataSource
) : CoroutineWorker(context, workerParams) {
    override suspend fun doWork(): Result {
        return try {
            remoteDataSource.syncUnsyncedTrees()
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
}

Hilt (Dependency Injection)

Hilt makes it easier to inject dependencies across your app's layers. By using Hilt, we ensure that the TreeRepository, SyncWorker, and other components are properly instantiated and have all the required dependencies.

@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {

    @Provides
    fun provideTreeRepository(
        dao: TreeDao,
        workManager: WorkManager
    ): TreeRepository = TreeRepository(dao, workManager)
    
    @Provides
    fun provideTreeRemoteDataSource(
        api: TreeApi,
        dao: TreeDao,
    ): TreeRepository = TreeRemoteDataSource(api, dao)
    
    /* provide retrofit and database instances */

}

With these core building blocks, your app is designed to be resilient, responsive, and scalable. The UI only interacts with the Repository, keeping business logic clean and testable. Background syncing is handled reliably by WorkManager, and Hilt ensures dependency management is smooth.

Next, we can dive into how the backend supports all this: managing syncing, conflict resolution, and ensuring data consistency.

Backend Sync & Conflict Handling

Designing an offline-first mobile app is only half the story. The backend must also be designed with offline behavior in mind.

Backend Responsibilities in Offline-First Architecture

To support mobile clients that sync intermittently, the backend should:

  1. Support idempotent operations: So that if the same payload is sent multiple times (due to retries), the backend processes it only once.
  2. Accept data with client-side timestamps: Allow clients to send createdAt, updatedAt, etc., so the server can sort out the latest changes.
  3. Track sync state and conflicts: Provide APIs that allow clients to sync unsynced data and fetch only what's changed.
  4. Enable soft deletes and audit trails: Offline clients may try to update or delete something that’s already changed or removed on the server.
  5. Use control layers or sync endpoints: Special endpoints for syncing batches of changes, rather than one-by-one REST calls.
// POST /api/sync/trees
[
  {
    "id": "c23d",               // client-generated ID
    "name": "orangina",
    "status": "healthy",
    "createdAt": "2025-04-10T08:00:00Z",
    "syncedAt": null,
    "updatedAt": "2025-04-10T08:00:00Z",
    "isSynced": false
  }
]

Example API Contract

{
  "synced": ["c23d"],
  "conflicts": [],
  "serverTimestamps": {
    "c23d": "2025-04-10T08:02:34Z"
  }
}

Response

The server acknowledges which items were synced, and can return conflict info if needed. Clients can then mark isSynced = true locally.

Conflicts are inevitable when syncing happens across time and space. Here’s how to deal with them:

Conflict Resolution Strategies

Last Write Wins (LWW)

This means the server accepts the latest update based on timestamp. This is best to use with simple forms and non-critical data.

Merge Strategy

Manual Resolution

If your domain requires auditability or traceability (e.g., forestry inspections), always log both versions and surface the conflict later.

Backend Sync Layer Design

A scalable pattern is to have a dedicated sync control layer:

  • Sync endpoints accept batches of offline data (e.g., /sync/trees, /sync/issues).
  • The control layer:
    • Validates and stores the batch
    • Updates records if necessary
    • Tracks versions
    • Returns confirmation or conflict info if need to be handled manually
  • Downstream services (e.g., analytics, reporting) consume the updated data without knowing the client was offline.

Now let’s wrap it all together and walk through a real-world data flow from the user’s action to backend sync—how the entire offline-first architecture behaves both when offline and when coming back online, including how the UI stays updated thanks to Room’s observable patterns.

Architecture Diagram & Recap

Designing an offline-first Android app isn’t about just caching data—it's about thinking end-to-end, from how users interact with the UI, to how data is stored locally and synced, and finally how the backend is structured to support it.

┌───────────────────────────────────────────────────────────────┐
│                          UI Layer                             │
│  ┌────────────────────────────────────────────────────────┐   │
│  │ Jetpack Compose observes Room DB (Flow / LiveData)     │   │
│  │ Trigger actions (e.g., report tree issue)              │   │
│  └────────────────────────────────────────────────────────┘   │
│                         ▲        │                            │
│                         │        ▼                            │
│               ┌────────────────────────────┐                  |
│               │         Repository         │                  │
│               │  - Always read from Room   │                  │
│               │  - Fetch remote in bg      │                  │
│               │  - Save unsynced data      │                  │
│               │  - Schedule WorkManager    │                  │
│               └────────────────────────────┘                  │
│                     ▲                 │                       │
│                     │                 ▼                       │
│    ┌────────────────────────┐     ┌─────────────────────┐     │
│    │      Room (Local DB)   │     │   WorkManager (bg)  │     │
│    │  - Trees & Issues DAO  │     │   - Triggers sync   |     |
|    |                        |     |   - Triggers fetch  │     │
│    └────────────────────────┘     └─────────────────────┘     │
│                     ▲                 :                       │
│                     │                 ▼                       │
│               ┌────────────────────────────┐                  │
│               │     Remote Data Source     │                  │
│               │   - Retrofit + API layer   │                  │
│               └────────────────────────────┘                  │
│                         ▲        |                            │
│                         :        ▼                            │
│               ┌────────────────────────────┐                  │
│               │       Backend Server       │                  │
│               │  - Sync endpoints          │                  │
│               │  - Conflict resolution     │                  │
│               │  - Idempotent APIs         │                  │
│               └────────────────────────────┘                  │
└───────────────────────────────────────────────────────────────┘

Key Design Principles Recap

Let’s revisit the core concepts we applied throughout this architecture:

  • Single Source of Truth: Room database is the only source the UI observes.
  • Always Available UX: All actions (create, read) work offline instantly.
  • Background Sync: Write operations trigger sync jobs via WorkManager.
  • Remote Refresh: Read operations silently pull fresh data and update Room.
  • Conflict Handling: Backend resolves conflicts or notifies client.
  • Hilt DI: Dependencies (e.g., TreeRepository, DAOs, APIs) are injected cleanly.
  • Composable & Decoupled: Layers are testable, reusable, and separated.

Wrapping Up

Designing Android apps that work offline is more than just caching—it's about rethinking how your app stores, syncs, and serves data when connectivity isn't guaranteed.

We covered:

  • A robust data layer architecture
  • Room + WorkManager for local storage and sync
  • Backend strategies that support sync without complexity
  • A seamless conflicts handling
  • Smooth User experience with no failure or dead ends.

This approach is already used in critical domains like farming, forest monitoring, and fieldwork—anywhere the internet is a luxury, not a given.

Key Use Cases for Offline-First Apps

Offline-first apps are particularly valuable in scenarios where internet connectivity is unreliable or unavailable. These apps are designed to function seamlessly without a constant internet connection, making them ideal for various industries and use cases.

  • Field Services: Workers in industries like agriculture, construction, and utilities can collect and access data in the field without relying on internet access, improving efficiency and data accuracy. This is what we built for Planboo, solving for how rural farmers can collect biochar data.
  • Healthcare: Healthcare professionals can use offline-first apps to access patient records and medical information in areas with poor connectivity, ensuring continuity of care. This is what we did for Hala Systems to allow for hospitals to triage and treat patients based on real-time available healthcare services. We've also consulted for Nala to create the same possibility.
  • Education: In educational settings, offline-first apps allow students to access learning materials without needing a stable internet connection, which is crucial in remote or underserved areas.
  • Travel and Hospitality: Offline-first apps enable travelers to access maps, itineraries, and local information without incurring roaming charges or needing a connection.

These apps are valuable because they create the possibility to solve for issues that previously had not way to work due to slow or unavailable internet connections.

Bonus: What to Build Next?

  • Add a sync status indicator in the UI
  • Allow manual sync trigger
  • Use NetworkType constraints in WorkManager
  • Log sync history for debugging
  • Integrate Firebase / Crashlytics to monitor sync failures

Frequently Asked Questions

What is an offline-first app?

An offline-first app is designed to perform all or a critical subset of its core functionalities without internet connectivity, ensuring usability even when network access is unreliable or unavailable.

Why is offline-first design important?

Offline-first design is crucial for ensuring network reliability, enhancing user experience, and optimizing resource efficiency, especially in areas with spotty or slow network connections.

How does WorkManager help in offline-first apps?

WorkManager manages background tasks that need to be executed reliably, such as syncing data with a remote server, ensuring that data changes made while offline are synchronized when the device is back online.

What are the key components of an offline-first app architecture?

The key components include a local data source (e.g., Room), a network data source (e.g., Retrofit), a repository pattern for data management, and WorkManager for background syncing.

How do offline-first apps handle data synchronization?

Offline-first apps use synchronization strategies like pull-based or push-based synchronization to reconcile local and network data sources, often employing conflict resolution strategies like 'last write wins'.

Share this story