androidengineers.Book a session

Data Synchronization

Eventual Consistency & UX

article20 minHard

Eventual consistency means the system will become consistent — eventually. The question for mobile UX is: what does the user see in the meantime, and how do you make the inconsistent state feel intentional rather than broken?

The Core UX Problem

When a user performs an action (like a post, a like, an order), there's a window where:

  • Their local state shows the result
  • The server may not have processed it yet
  • Other devices won't see it yet

Poorly handled, this creates ghost states: a like button that flips back, a deleted item that reappears, a sent message that disappears from history.

Optimistic UI

Show the result immediately, sync in background. The fastest and most satisfying UX when the operation is likely to succeed.

class FeedViewModel(private val repository: FeedRepository) : ViewModel() {

    private val _posts = MutableStateFlow<List<Post>>(emptyList())
    val posts: StateFlow<List<Post>> = _posts.asStateFlow()

    fun likePost(postId: String) {
        // 1. Update UI immediately
        _posts.update { posts ->
            posts.map { post ->
                if (post.id == postId) post.copy(
                    isLiked = !post.isLiked,
                    likeCount = if (post.isLiked) post.likeCount - 1 else post.likeCount + 1
                ) else post
            }
        }

        // 2. Sync to server in background
        viewModelScope.launch {
            val success = repository.toggleLike(postId)
            if (!success) {
                // 3. Roll back on failure
                _posts.update { posts ->
                    posts.map { post ->
                        if (post.id == postId) post.copy(
                            isLiked = !post.isLiked,
                            likeCount = if (post.isLiked) post.likeCount + 1 else post.likeCount - 1
                        ) else post
                    }
                }
                showError("Couldn't update like — check your connection")
            }
        }
    }
}

Pending State Indicators

When you can't confirm success immediately, show pending state instead of either "done" or "error":

data class Post(
    val id: String,
    val content: String,
    val syncState: SyncState = SyncState.SYNCED
)

enum class SyncState { SYNCED, PENDING, FAILED }
<!-- In item layout: show a subtle sync indicator -->
<ProgressBar
    android:id="@+id/syncIndicator"
    android:visibility="@{post.syncState == SyncState.PENDING ? View.VISIBLE : View.GONE}" />

<ImageView
    android:id="@+id/failureIndicator"
    android:src="@drawable/ic_sync_error"
    android:visibility="@{post.syncState == SyncState.FAILED ? View.VISIBLE : View.GONE}" />

Handling Failure States

Design the failure path as carefully as the success path:

FailureUX Response
Temporary network errorShow pending indicator; retry silently
Persistent failureShow error badge; offer manual retry button
ConflictShow both versions; ask user to resolve
Server rejection (validation)Roll back optimistic update; explain reason
// Don't show errors for transient failures — retry silently
fun handleSyncResult(result: SyncResult, postId: String) {
    when (result) {
        is SyncResult.Success -> markSynced(postId)
        is SyncResult.Temporary -> scheduleRetry(postId)  // no user-visible error
        is SyncResult.Permanent -> {
            markFailed(postId)
            showRetryOption(postId)  // user-visible: retry button
        }
    }
}

Showing Pending Operations in Lists

// In adapter/Compose: render pending items differently
@Composable
fun PostItem(post: Post) {
    Box {
        PostContent(post)

        if (post.syncState == SyncState.PENDING) {
            // Subtle overlay, don't block interactions
            LinearProgressIndicator(
                modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
                color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
            )
        }

        if (post.syncState == SyncState.FAILED) {
            Icon(
                imageVector = Icons.Default.SyncProblem,
                contentDescription = "Sync failed",
                modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
                tint = MaterialTheme.colorScheme.error
            )
        }
    }
}

UX Principles for Eventual Consistency

  1. Never show a blank screen while waiting for sync — serve from local cache immediately
  2. Optimistic updates for reversible actions (likes, toggles, preferences) — rollback on failure
  3. Pending state for irreversible actions (payments, destructive deletes) — confirm before committing
  4. Silent retry for transient failures — only surface the error after N retries
  5. Preserve user's intent — queue offline writes; don't discard them silently
  6. Conflict resolution UI — don't pick a winner without asking the user for ambiguous conflicts

Key Takeaways

Eventual consistency isn't a problem to hide from users — it's a state to communicate clearly. The best apps make it feel fast (optimistic updates), trustworthy (pending indicators that resolve), and recoverable (retry options that work).

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Eventual Consistency & UX | Android System Design | Android Engineers