androidengineers.Book a session

Designing a News Feed/Social App

Like/Comment Systems & Consistency

article25 minHard

Like and comment features seem simple but hide significant consistency challenges: optimistic updates, rollbacks on failure, preventing double-likes, and real-time count synchronization across devices.

Optimistic Updates: The UX Requirement

Users expect instantaneous feedback when they tap like. You can't wait for a server round-trip before updating the UI:

class FeedViewModel(
    private val feedRepository: FeedRepository,
    private val postRepository: PostRepository
) : ViewModel() {

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

    fun toggleLike(postId: String) {
        val currentPosts = _posts.value
        val post = currentPosts.find { it.id == postId } ?: return
        val wasLiked = post.isLikedByMe

        // Step 1: Optimistic update — immediately update UI
        _posts.update { posts ->
            posts.map { p ->
                if (p.id == postId) p.copy(
                    isLikedByMe = !wasLiked,
                    likeCount = p.likeCount + if (wasLiked) -1 else 1
                )
                else p
            }
        }

        // Step 2: Send to server
        viewModelScope.launch {
            try {
                if (wasLiked) {
                    postRepository.unlike(postId)
                } else {
                    postRepository.like(postId)
                }
                // Step 3a: Success — fetch true count from server to sync
                val updatedPost = postRepository.getPost(postId)
                _posts.update { posts ->
                    posts.map { if (it.id == postId) updatedPost else it }
                }
            } catch (e: Exception) {
                // Step 3b: Failure — rollback the optimistic update
                _posts.update { posts ->
                    posts.map { p ->
                        if (p.id == postId) p.copy(
                            isLikedByMe = wasLiked,
                            likeCount = p.likeCount + if (wasLiked) 1 else -1
                        )
                        else p
                    }
                }
                _effects.emit(FeedEffect.ShowError("Failed to update like"))
            }
        }
    }
}

Preventing Double-Likes

class PostRepository(private val api: PostApi, private val db: PostDao) {
    // Track in-flight requests to prevent duplicate likes
    private val pendingLikes = ConcurrentHashMap<String, Job>()

    suspend fun like(postId: String) {
        // Cancel any existing pending operation for this post
        pendingLikes[postId]?.cancel()

        val job = coroutineScope {
            launch {
                api.likePost(postId)
                db.updateLikeState(postId, isLiked = true)
            }
        }
        pendingLikes[postId] = job
        job.join()
    }
}

Comment Threading

data class Comment(
    val id: String,
    val postId: String,
    val parentCommentId: String?,  // null = top-level comment
    val authorId: String,
    val body: String,
    val replyCount: Int,
    val createdAt: Long
)

// Fetch top-level comments + one level of replies
suspend fun getComments(postId: String): List<CommentWithReplies> {
    val topLevel = api.getComments(postId, parentId = null)
    val withReplies = topLevel.map { comment ->
        val replies = if (comment.replyCount > 0) {
            api.getComments(postId, parentId = comment.id)
                .take(3)  // show first 3 replies inline
        } else emptyList()
        CommentWithReplies(comment, replies, hasMoreReplies = comment.replyCount > 3)
    }
    return withReplies
}

Optimistic Comment Posting

fun postComment(postId: String, body: String) = viewModelScope.launch {
    val tempId = "temp_${System.currentTimeMillis()}"
    val optimisticComment = Comment(
        id = tempId,
        postId = postId,
        authorId = currentUserId,
        body = body,
        replyCount = 0,
        createdAt = System.currentTimeMillis(),
        isOptimistic = true  // shown with visual indicator (dimmed / spinner)
    )

    // Optimistically add to list
    _comments.update { it + optimisticComment }

    try {
        val serverComment = api.postComment(postId, body)

        // Replace temp comment with real one from server
        _comments.update { comments ->
            comments.map { if (it.id == tempId) serverComment else it }
        }
    } catch (e: Exception) {
        // Remove the optimistic comment and show error
        _comments.update { comments -> comments.filter { it.id != tempId } }
        _effects.emit(FeedEffect.ShowError("Failed to post comment"))
    }
}

Count Synchronization via WebSocket

class RealtimeCountSyncer(private val socket: FeedWebSocket) {
    fun observeCounts(postId: String): Flow<CountUpdate> =
        socket.events
            .filterIsInstance<FeedEvent.CountUpdate>()
            .filter { it.postId == postId }
            .map { CountUpdate(likes = it.likeCount, comments = it.commentCount) }
}

// In ViewModel:
LaunchedEffect(postId) {
    realtimeCountSyncer.observeCounts(postId).collect { counts ->
        _posts.update { posts ->
            posts.map { if (it.id == postId) it.copy(likeCount = counts.likes) else it }
        }
    }
}

Key Takeaways

PatternRule
Optimistic updateUpdate UI immediately; rollback on server error
RollbackAlways restore previous state on failure; show error to user
True count syncAfter a successful like, fetch the server's count — don't trust local math
Pending request dedupUse ConcurrentHashMap<postId, Job> to cancel duplicate requests
Optimistic commentsShow with visual indicator (dim/spinner); replace with server ID on success
Realtime countsWebSocket/SSE pushes counts; don't rely solely on REST polling

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Like/Comment Systems & Consistency | Android System Design | Android Engineers