Sitemap

Offline-first Application: Uninterrupted User Experience

12 min readSep 28, 2025

--

Press enter or click to view image in full size
Offline-first Pattern Overview

This article explores the offline-first pattern and how it can create a seamless UI by minimizing waiting for data. Note: Edited in 2025/10/4.

Introduction

How can you avoid blocking user interactions while fetching data from the server?

Have you ever accidentally clicked on a page and been forced to wait for it to load? Or left a page before it finished loading, only to wait again the next time you visit? This frustrating experience happens because data fetching and user interactions are asynchronous but tightly coupled, forcing one to wait for the other.

Architectural Design Overview

Offline-first Pattern

Offline-first Data Flow

An offline-first application allows its users to access all or part of its functionality without an internet connection. To provide this capability, we need two types of data sources:

  • Local Data Source:
    The only data that is always available comes from local storage. To support an offline-first approach, we cache data locally. The cache may reside in memory, a database, or another storage mechanism.
  • Remote Data Source:
    Server data is required to populate local storage. Remote data sources are where the client fetches data from the server.

For any data retrieved from a remote source, we create a corresponding local data source to provide cached access. The local data source serves as the single source of truth regardless of internet connectivity.

Synchronization

Two Common Synchronization Methods

To populate local storage with remote data, there are two common methods for an offline-first application to synchronize with server:

  • Pull-based Synchronization:
    The application requests the latest data on user demand. For example, a user can swipe to refresh the photo list in the GalleryFragment. This approach may not always present the most up-to-date data, since the timing of requests is not synchronized with server updates.
  • Push-based Synchronization:
    In this method, the application uses push notifications to determine the exact timing for fetching data. Since notifications are sent from the server, the application knows precisely when the data is updated, reducing the likelihood of showing outdated information. However, this method is more complex to implement, as it requires integrating a push notification service.

Pull-based synchronization is simple to implement but risks missing server updates and showing stale data. For a more real-time experience, push-based synchronization can be used to fetch data immediately after remote updates.

Write Strategies

Lazy Write Strategy Overview

If your application allows its users to make changes, it will also need to upload the edited info. There are two common methods to handle this task:

  • Online-only Writes:
    In this approach, users can write data only when a network connection is available. The data is immediately sent to the server, which responds right after. It should be reserved only for critical operations because it blocks user interactions until the network responds.
  • Lazy Writes:
    Writes are cached locally and uploaded once a network connection becomes available. Since users only wait for local I/O, this method does not interrupt interactions. However, it requires implementing local storage in a way that can track edited data for upload. If the data is also being pulled from the server, a conflict resolution strategy such as last-write-wins must be implemented.

Despite its added complexity, lazy writes are generally the best default strategy for offline-first applications. Online-only writes should be reserved for critical operations.

Task Scheduling

Press enter or click to view image in full size
Pull-based Synchronization Scheduling

With pull-based synchronization, it is possible that a user may never trigger an update for a long time. It is generally unacceptable to display overly outdated data:

  • To prevent this, we need to schedule periodic jobs that automatically fetch remote data. On Android, the WorkManager library provides the required functionalities for this task.
  • For lazy writes to work properly, we also need to handle network constraints. WorkManager supports this through Constraints which allows jobs to run only when conditions such as network availability are met.
  • By using task scheduling, I/O operations with the server are detached from the UI and can run independently.

If you want to build an offline-first application, WorkManager already supports network detection, periodic updates, and background execution independent of the UI.

These patterns are also discussed in the official Android documentation. I’ll include the links here and cover the implementation in the next section.

Implementation Details

Data Sources Integration

interface BookRepository {
suspend fun fetchBooks()
fun getBookListFlow(): Flow<List<BookDO>>
suspend fun markAsFavorite(
bookId: String,
isFavorite: Boolean,
updateTime: Long
)
suspend fun uploadFavoriteStatus()
}

class BookRepositoryImp(
private val bookDao: BookDao, // Local Database
private val bookService: Book Service // Remote API Client
): BookRepository {
// Synchronization
override suspend fun fetchBooks() {
bookDao.upsert(bookService.fetchBooks().map {
//...//
})
}

// The Single Source of Truth of Books
override fun getBookListFlow() = bookDao.getBookListFlow().map {
//...//
}

// User Interaction Submission
override suspend fun markAsFavorite(
bookId: String,
isFavorite: Boolean,
updateTime: Long
) {
bookDao.upsertLocalStatus(entity = LocalStatusEntity(
bookId = bookId,
isFavorite = isFavorite,
updateTime = updateTime
))
}

// User Data Upload
override suspend fun uploadFavoriteStatus() {
val editedBooks = bookDao.getEditedBooks()

bookService.uploadFavoriteStatuses(editedBooks.map {
//...//
})

// Clear local statuses and update BookEntity list.
bookDao.updateBooksWithLocalStatuses(editedBooks)
}
}

BookRepository integrates the local data source (BookDao) and the remote data source (BookService). Its data flow is the core of the offline-first pattern:

  • There is no need to explicitly declare separate local and remote data source classes. The focus here is on how the data flows from the remote source to the local storage.
  • BookDao saves the books fetched by calling BookService.fetchBooks() and exposes the data through a Flow. Consumers only need to observe it to get the latest results.
  • BookDao also stores user edits for later uploads by calling BookDao.markAsFavorite(). When the internet is available, consumers can call uploadFavoriteStatuses() to send local changes to the server.
  • Thus, BookDao becomes the middle layer between the remote data and UI rendering, so the user interactions are not interrupted by the absence of connectivity.

Pull-based Synchronization

@Composable
fun BookListScreen(
viewModel: BookListViewModel = viewModel()
) {
val bookList by viewModel.bookListFlow.collectAsStateWithLifecycle()

var isBookListFetched by remember{ mutableStateOf(false) }

// Fetch books if it hasn’t been tried yet
LaunchedEffect(isBookListFetched) {
if (!isBookListFetched) {
viewModel.fetchBooks()

isBookListFetched = true
}
}

// Render the books with the layout Composable
BookListLayout(
bookList = bookList,
onRefresh = {
// Trigger fetching by user interaction
viewModel.fetchBooks()
}
)
}

@Composable
fun BookListLayout(
bookList: List<BookDO>,
onRefresh: () -> Unit
) {
//...//
}

Get zerg 1111’s stories in your inbox

Join Medium for free to get updates from this writer.

When the user navigates to BookListScreen, it triggers a call to fetch the latest books. This is a classic example of pull-based synchronization which is literally synchronization on demand:

  • isBookListFetched is a flag used to track whether the fetch call has already been made for this screen.
  • The LaunchedEffect block is triggered only when the value of isBookListFetched changes.
  • When isBookListFetched is false, it triggers viewModel.fetchBooks() and then sets isBookListFetched to true.
  • BookListViewModel reflects the result by updating its bookListFlow, which in turn updates the UI.

Push-based Synchronization

@AndroidEntryPoint
class MyFCMService : FirebaseMessagingService() {
@Inject
lateinit var bookRepository: BookRepository

private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())

private var fetchBookListJob: Job? = null

override fun onMessageReceived(remoteMessage: RemoteMessage) {
var isBookListUpdated = with(remoteMessage) {
// Check if the book list has been updated on the server
//...//
}

if (isBookListUpdated) {
fetchBookListJob?.cancel()

fetchBookListJob = serviceScope.launch {
bookRepository.fetchBooks()
}
}
}

override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
}

When the book list is updated on the server, a notification is sent and received by clients to trigger fetching. In this case, we use Firebase Cloud Messaging, and Hilt for the example:

  • BookRepository is injected into MyFCMService using Hilt to provide data access inside the service.
  • A CoroutineScope is created to invoke suspend functions. Since it is the root scope of the service, we don’t want a single exception to crash the entire scope, so we use a SupervisorJob.
  • In onMessageReceived(), we check whether the server has updated the book list. If it has, we invoke BookRepository.fetchBooks() to synchronize.
  • If your data is fetched by many users at the same time, consider applying a random delay before fetching to avoid traffic spikes.

Lazy Writes

@Composable
fun BookEditScreen(
viewModel: BookEditViewModel = viewModel()
) {
val book = viewModel.bookStateFlow.collectAsStateWithLifecycle()

BookEditLayout(
book = book,
onMarkAsFavorite = { isMarked ->
// Change book status and mark it as edited.
// This applies to local storage and is reflected immediately.
viewModel.markAsFavorite(
bookId = book.bookId,
isMarked = isMarked,
updateTime = System.currentTimeMillis()
)
}
)
}

@Composable
fun BookEditLayout(
book: BookDO,
onMarkAsFavorite: (isMarked: Boolean) -> Unit
) {
//...//
}

@AndroidEntryPoint
class MyApp: Application() {
@Inject
lateinit var bookRepository: BookRepository

private val appScope = CoroutineScope(Dispatchers.Main + SupervisorJob())

private var uploadFavoriteStatusJob: Job? = null

override onCreate() {
super.onCreate()
getSystemService(ConnectivityManager::class.java)
.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {

//...//

override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) {
// Upload previously edited books to the server when network is available
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) {
uploadFavoriteStatusJob?.cancel()

uploadFavoriteStatusJob = appScope.launch {
bookRepository.uploadFavoriteStatus()
}
}
}

//...//

})

}
}

Lazy writes let the application cache user interactions without uploading immediately. Once connectivity is available, the changes are uploaded to the server in the background:

  • BookEditViewModel.markAsFavorite() changes the favorite status of a book and marks it for later uploading.
  • In onCapabilitiesChanged(), we invoke BookRepository.uploadFavoriteStatus() to complete synchronization.
  • This example is intentionally simplified. In a real application, you might want to upload changes as soon as possible. We will cover the concern later with job scheduling mechanisms.

I/O Conflict Resolution

@Entity(primaryKeys = [Column.BOOK_ID])
data class BookEntity(
@ColumnInfo(name = Column.BOOK_ID) val bookId: String,
@ColumnInfo(name = Column.DESCRIPTION) val description: String,
@ColumnInfo(name = Column.IS_FAVORITE) val isFavorite: Boolean,
@ColumnInfo(name = Column.UPDATE_TIME) val updateTime: Long
)

@Entity(
primaryKeys = [Column.BOOK_ID],
foreignKeys = [ForeignKey(
childColumns = [Column.BOOK_ID],
entity = BookEntity::class,
onDelete = ForeignKey.CASCADE,
parentColumns = [Column.BOOK_ID]
)]
)
data class LocalStatusEntity(
@ColumnInfo(name = Column.BOOK_ID) val bookId: String,
@ColumnInfo(name = Column.IS_FAVORITE) val isFavorite: Boolean,
@ColumnInfo(name = Column.UPDATE_TIME) val updateTime: Long
)

@DatabaseView(
"WITH Temp AS( SELECT " +
"BookEntity.${Column.BOOK_ID} AS ${Column.BOOK_ID}, " +
"BookEntity.${Column.DESCRIPTION} AS ${Column.DESCRIPTION}, " +
"CASE " +
"WHEN LocalStatusEntity.${Column.UPDATE_TIME} IS NOT NULL AND " +
"LocalStatusEntity.${Column.UPDATE_TIME} > BookEntity.${Column.UPDATE_TIME} " +
"THEN 1 ELSE 0 " +
"END AS ${Column.IS_EDITED}, " +
"LocalStatusEntity.${Column.IS_FAVORITE} AS ${Column.IS_LOCAL_FAVORITE}, " +
"BookEntity.${Column.IS_FAVORITE} AS ${Column.IS_REMOTE_FAVORITE}, " +
"LocalStatusEntity.${Column.UPDATE_TIME} AS ${Column.LOCAL_UPDATE_TIME}, " +
"BookEntity.${Column.UPDATE_TIME} AS ${Column.REMOTE_UPDATE_TIME} " +
"From BookEntity " +
"LEFT JOIN LocalStatusEntity ON LocalStatusEntity.${Column.BOOK_ID} = BookEntity.${Column.BOOK_ID}" +
") " +

"SELECT " +
"Temp.${Column.BOOK_ID} AS ${Column.BOOK_ID}, " +
"Temp.${Column.DESCRIPTION} AS ${Column.DESCRIPTION}, " +
"Temp.${Column.IS_EDITED} AS ${Column.IS_EDITED}, " +
"CASE " +
"WHEN Temp.${Column.IS_EDITED} = 1 " +
"THEN Temp.${Column.IS_LOCAL_FAVORITE} ELSE Temp.${Column.IS_REMOTE_FAVORITE} " +
"END AS ${Column.IS_FAVORITE}, " +
"CASE " +
"WHEN Temp.${Column.IS_EDITED} = 1 " +
"THEN Temp.${Column.LOCAL_UPDATE_TIME} ELSE Temp.${Column.REMOTE_UPDATE_TIME} " +
"END AS ${Column.UPDATE_TIME} " +
"FROM Temp"
)
data class BookView(
@ColumnInfo(name = Column.BOOK_ID) val bookId: String,
@ColumnInfo(name = Column.DESCRIPTION) val description: String,
@ColumnInfo(name = Column.IS_EDITED) val isEdited: Boolean,
@ColumnInfo(name = Column.IS_FAVORITE) val isFavorite: Boolean,
@ColumnInfo(name = Column.UPDATE_TIME) val updateTime: Long
)

interface BookDao {
@Transaction
fun updateBooksWithLocalStatuses(editedBooks: List<BookView>) {
editedBooks.forEach {
updateIsFavorite(
bookId = it.bookId,
isFavorite = it.isFavorite,
updateTime = it.updateTime
)

clearLocalStatus(bookIds = it.bookId)
}
}

@Query("DELETE FROM LocalStatusEntity WHERE ${Column.BOOK_ID} = :bookId")
fun clearLocalStatus(bookId: String)

@Query("SELECT * FROM BookView")
fun getBookListFlow(): Flow<List<BookView>>

@Query("SELECT * FROM BookView WHERE ${Column.IS_EDITED} = 1")
fun getEditedBooks(): List<BookView>

@Query(
"UPDATE BookEntity " +
"SET ${Column.IS_FAVORITE} = :isFavorite, " +
"${Column.UPDATE_TIME} = :updateTime " +
"WHERE ${Column.BOOK_ID} = :bookId"
)
fun updateIsFavorite(bookId: String, isFavorite: Boolean, updateTime: Long)

@Upsert
fun upsertBooks(entities: List<BookEntity>)

@Upsert
fun upsertLocalStatus(entity: LocalStatusEntity)
}

Assuming the favorite status is also returned from the server to support multiple devices, it may conflict with local changes, so we implement a last-write-wins (LWW) mechanism to resolve conflicts:

  • LocalStatusEntity stores user edits done locally.
  • When querying the book list, we compare UPDATE_TIME in BookEntity and LocalStatusEntity to determine which IS_FAVORITE value to use. It means the latest update wins.
  • The same comparison also determines whether the favorite status is edited locally. This allows querying for edited books that need to be uploaded.
  • After successfully uploading local edits, we clear the uploaded LocalStatusEntity entries and update BookEntity accordingly to prevent re-uploading.

Sync Task Scheduling

@HiltWorker
class BooksSyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val bookRepository: BookRepository
): CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
bookRepository.fetchBooks()

Result.success()
} catch (e: Exception) {
if (e is CancellationException)
throw e
else
Result.retry()
}
}

companion object {
fun Context.scheduleBooksSync() =
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
uniqueWorkName = "BooksSyncWorker",
existingWorkPolicy = ExistingPeriodicWorkPolicy.KEEP,
work = PeriodicWorkRequestBuilder<BooksUploadWorker>(
repeatInterval = 1,
repeatIntervalTimeUnit = TimeUnit.DAYS,
flexInterval = 12,
flexIntervalTimeUnit = TimeUnit.HOURS
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
)
}
}

@HiltAndroidApp
class ExampleApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory

override fun onCreate() {
super.onCreate()
scheduleBooksSync()
}

override fun getWorkManagerConfiguration() =
Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}

With WorkManager, it is simple to schedule a task to periodically refresh the local cache to keep data from becoming outdated:

  • To access BookRepository in BooksSyncWorker, we use Hilt integration to inject the instance.
  • In doWork(), we call bookRepository.fetchBooks(). If the operation succeeds, we return Result.success(). If it fails, we can return Result.retry() by wrapping the call in a try-catch block.
  • Here, the job is scheduled to run once per day, with a flex interval of 12 hours. This flex interval helps mitigate traffic spikes when many users fetch data simultaneously.
  • The NetworkType.CONNECTED constraint ensures that the job only runs when the device has an internet connection. If the device is offline, the task will be pending and automatically executed once connectivity is restored.
  • This can work with the previous example shown in the pull-based synchronization section. Let users fetch the latest books on demand in the UI while the background worker keeps the cache reasonably up-to-date for future visits.

Upload Task Scheduling

@HiltWorker
class BooksUploadWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val bookRepository: BookRepository
): CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
return try {
bookRepository.uploadFavoriteStatus()

Result.success()
} catch (e: Exception) {
if (e is CancellationException)
throw e
else
Result.retry()
}
}

companion object {
fun Context.scheduleBooksUpload() =
WorkManager.getInstance(this).enqueueUniqueWork(
uniqueWorkName = "BooksUploadWorker",
existingWorkPolicy = ExistingWorkPolicy.REPLACE,
work = OneTimeWorkRequestBuilder<BooksUploadWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
)
}
}

@Composable
fun BookEditScreen2(
viewModel: BookEditViewModel = viewModel()
) {
val actions = viewModel.stateFlow.map { it.actions }.collectAsStateWithLifecycle()
val book = viewModel.bookStateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current

LaunchedEffect(actions) {
actions.forEach { action ->
when(action) {
// Once the change is written to the local cache,
// we can schedule the upload job, which will be executed when the internet is available.
BookEditViewModel.State.Action.UploadFavoriteStatuses ->
context.scheduleBooksUpload()

}
}
}

BookEditLayout(
book = book,
onMarkAsFavorite = { isMarked ->
// Change book status and mark it as edited.
// This applies to local storage and is reflected immediately.
viewModel.markAsFavorite(
bookId = book.bookId,
isMarked = isMarked,
updateTime = System.currentTimeMillis()
)
}
)
}

To support offline editing, we must ensure that changes are uploaded when the network is available. This can be achieved by using WorkManager with the NetworkType.CONNECTED constraint:

  • scheduleBooksUpload() schedules a unique one-time work that replaces any previously pending job, ensuring that the latest data is always uploaded.
  • BookEditScreen2 triggers BookEditViewModel.State.Action.UploadFavoriteStatuses after finishing saving data to the local storage.
  • This ensures that whenever a change is made, there is always a pending task ready to upload the update.

Pros and Cons

  • Pros:
    The main advantage of this pattern is that it eliminates most interruptions caused by network communication. The UI can observe local storage passively without manually updating values when server responses arrive.
  • Cons:
    The mechanisms behind lazy writes and I/O conflict resolution are quite complex. While they offer clear benefits, they can be costly to implement.

Conclusion

This experience comes from an e-book reader project where users can make notes. I was requested to support offline operations so that users could read books without connections. The offline-first pattern was a natural solution because there were many potential I/O conflicts across multiple devices per user. While the full mechanism may not be necessary for all applications, I still recommend making the local data source the single source of truth because it provides the most value at minimal cost.

--

--

zerg 1111
zerg 1111

Written by zerg 1111

A senior Android developer specializing in application architecture. Looking for a senior Android developer role: https://www.cake.me/b9915034