Offline-first Application: Uninterrupted User Experience
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
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
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 theGalleryFragment. 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
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
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
Constraintswhich 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.
BookDaosaves the books fetched by callingBookService.fetchBooks()and exposes the data through aFlow. Consumers only need to observe it to get the latest results.BookDaoalso stores user edits for later uploads by callingBookDao.markAsFavorite(). When the internet is available, consumers can calluploadFavoriteStatuses()to send local changes to the server.- Thus,
BookDaobecomes 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:
isBookListFetchedis a flag used to track whether the fetch call has already been made for this screen.- The
LaunchedEffectblock is triggered only when the value ofisBookListFetchedchanges. - When
isBookListFetchedisfalse, it triggersviewModel.fetchBooks()and then setsisBookListFetchedtotrue. BookListViewModelreflects the result by updating itsbookListFlow, 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:
BookRepositoryis injected intoMyFCMServiceusing Hilt to provide data access inside the service.- A
CoroutineScopeis 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 aSupervisorJob. - In
onMessageReceived(), we check whether the server has updated the book list. If it has, we invokeBookRepository.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 invokeBookRepository.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:
LocalStatusEntitystores user edits done locally.- When querying the book list, we compare
UPDATE_TIMEinBookEntityandLocalStatusEntityto determine whichIS_FAVORITEvalue 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
LocalStatusEntityentries and updateBookEntityaccordingly 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
BookRepositoryinBooksSyncWorker, we use Hilt integration to inject the instance. - In
doWork(), we callbookRepository.fetchBooks(). If the operation succeeds, we returnResult.success(). If it fails, we can returnResult.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.CONNECTEDconstraint 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.BookEditScreen2triggersBookEditViewModel.State.Action.UploadFavoriteStatusesafter 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.








