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:
mark refresh as running
fetch the remote snapshot
validate and map the response
commit the accepted snapshot atomically
mark refresh as idle
on expected refresh failure:
retain the local snapshot
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.
Top comments (0)