Offline-First Mobile: A Refresh Error Should Not Erase Good Data
DEV Community

Offline-First Mobile: A Refresh Error Should Not Erase Good Data

Open a mobile app on a weak connection. A list appears from yesterday's cache, refresh starts, the request fails - and the entire screen becomes an error page.

The app had useful data. Its state model simply had nowhere to put both that data and a refresh failure. This is a practical design problem for Android and Kotlin Multiplatform: content availability and refresh status are different facts.

Model two dimensions

An exclusive Loading / Success / Error model is convenient for an initial request. It becomes restrictive when a screen can show cached content while refreshing.

Consider this shared presentation model:

data class ArticleSummary(val id: String, val title: String)

sealed interface RefreshStatus {
  data object Idle : RefreshStatus
  data object Running : RefreshStatus
  data object Failed : RefreshStatus
}

data class ArticleListState(
  val items: List<ArticleSummary> = emptyList(),
  val hasLocalSnapshot: Boolean = false,
  val refresh: RefreshStatus = RefreshStatus.Idle,
)

hasLocalSnapshot distinguishes β€œwe have never loaded data” from β€œwe loaded an empty list.” Without that distinction, a legitimate empty response can look like unfinished loading forever.

The refresh state answers whether synchronization is happening. It does not replace the content.

Give reads one route

For a read-only feed, a useful repository contract is:

import kotlinx.coroutines.flow.Flow

interface ArticleRepository {
  fun observeLocal(): Flow<List<ArticleSummary>?>
  suspend fun refresh()
}

Here, null means no stored snapshot yet, while an empty list is a stored empty result. This is one possible contract, not a universal database convention.

The UI observes the local stream. A refresh fetches remote data and commits the accepted result to local storage. The local observation then supplies the updated content. Avoid making the screen race a network result against a separate database result.

Persistence, networking, and scheduling still need implementations appropriate to the supported platforms. A shared repository interface does not make Android background execution APIs portable to iOS.

Keep failure from becoming deletion

This conceptual refresh sequence makes the ordering visible:

  1. mark refresh as running
  2. fetch the remote snapshot
  3. validate and map the response
  4. commit the accepted snapshot atomically
  5. mark refresh as idle
  6. on expected refresh failure: retain the local snapshot
  7. mark refresh as failed

Do not clear storage at the start of refresh. A failed request should not destroy data merely to make room for a result that never arrives.

However, retaining everything forever is not a synchronization strategy. A successful authoritative empty snapshot may need to remove old rows. A paginated response normally cannot replace the entire dataset. Define whether the response is a complete snapshot, a page, or a delta before choosing a database operation.

Turn combinations into UI decisions

Local state Refresh state Useful presentation
No snapshot Running Initial loading view
No snapshot Failed Full error with retry
Cached items Running Existing list with refresh indicator
Cached items Failed Existing list with a retry message
Stored empty result Idle Intentional empty state

These are product choices, but making them explicit prevents accidental behavior. Show a β€œlast updated” value when freshness matters, using persisted metadata rather than the time the composable happened to render.

Handle the next race before it ships

If two refreshes overlap, an older response can arrive after a newer one. Pick a policy: serialize refreshes, cancel obsolete work where appropriate, or reject responses using a suitable version check. A database transaction prevents a partially written snapshot; it does not automatically decide which response is newest.

Account changes also need ownership rules. Partition or clear account-specific data deliberately and cancel old work so a previous user's response cannot populate the next user's screen.

For edits and queued writes, go further: define durable operations, server deduplication, conflict handling, and how pending state is shown. A cached read feed alone is not a complete offline synchronization engine.

Verify the behavior users notice

Test a stored non-empty list followed by a refresh failure. The list should remain. Then test an accepted empty snapshot, restart the process, and confirm the app still knows the result was empty. That pair of cases often reveals more than a successful network demo.

Offline-first behavior starts with preserving a coherent local view and making uncertainty visible.

References

  • Android Developers: Build an offline-first app
  • Android Developers: Data layer and source of truth
  • Android Developers: Guide to app architecture
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.