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
| Pattern | Rule |
|---|---|
| Optimistic update | Update UI immediately; rollback on server error |
| Rollback | Always restore previous state on failure; show error to user |
| True count sync | After a successful like, fetch the server's count — don't trust local math |
| Pending request dedup | Use ConcurrentHashMap<postId, Job> to cancel duplicate requests |
| Optimistic comments | Show with visual indicator (dim/spinner); replace with server ID on success |
| Realtime counts | WebSocket/SSE pushes counts; don't rely solely on REST polling |