A live social feed must merge new incoming posts with existing content seamlessly — no jarring scroll jumps, no duplicate items, and no stale content. The key tools are DiffUtil (for RecyclerView) and stable keys (for Compose LazyColumn).
The Problem: Naive Updates Break Scroll Position
// ❌ Replace entire list — RecyclerView scrolls to top, loses position
adapter.submitList(newPosts) // DiffUtil handles differences, but prepending jumps
// ❌ Insert at position 0 — abrupt, disorienting
adapter.notifyItemInserted(0) // scrolls view unless we explicitly compensate
DiffUtil: Efficient List Updates
DiffUtil computes the minimal set of changes between two lists:
class PostDiffCallback : DiffUtil.ItemCallback<Post>() {
// Fast identity check — determines whether the "same item" is being shown
override fun areItemsTheSame(oldItem: Post, newItem: Post): Boolean =
oldItem.id == newItem.id
// If same item, check if any visible content changed
override fun areContentsTheSame(oldItem: Post, newItem: Post): Boolean =
oldItem == newItem // data class equals checks all fields
// Optional: partial updates (only rebind changed fields, not the whole item)
override fun getChangePayload(oldItem: Post, newItem: Post): Any? {
return if (oldItem.likeCount != newItem.likeCount) PayloadType.LIKE_COUNT
else if (oldItem.commentCount != newItem.commentCount) PayloadType.COMMENT_COUNT
else null
}
}
class PostAdapter : ListAdapter<Post, PostViewHolder>(PostDiffCallback()) {
override fun onBindViewHolder(holder: PostViewHolder, position: Int, payloads: List<Any>) {
if (payloads.isEmpty()) {
super.onBindViewHolder(holder, position, payloads)
} else {
// Partial bind — only update the changed fields
payloads.forEach { payload ->
when (payload) {
PayloadType.LIKE_COUNT -> holder.bindLikeCount(getItem(position).likeCount)
PayloadType.COMMENT_COUNT -> holder.bindCommentCount(getItem(position).commentCount)
}
}
}
}
}
Prepend New Posts Without Scroll Jump
class FeedViewModel : ViewModel() {
private val _newPostsCount = MutableStateFlow(0)
val newPostsCount = _newPostsCount.asStateFlow()
// Don't prepend immediately — show "New posts" banner
fun onNewPostsReceived(newPosts: List<Post>) {
_newPostsCount.update { it + newPosts.size }
pendingNewPosts.addAll(newPosts)
}
// Called when user taps "Show N new posts"
fun showNewPosts() {
val current = _posts.value
_posts.value = pendingNewPosts + current
pendingNewPosts.clear()
_newPostsCount.value = 0
}
}
// In Compose:
if (newPostsCount > 0) {
Box(Modifier.fillMaxWidth().clickable { viewModel.showNewPosts() }) {
Text("Show $newPostsCount new posts", Modifier.align(Alignment.Center))
}
}
Stable Keys in Compose LazyColumn
LazyColumn {
items(
items = posts,
key = { post -> post.id } // stable key — tells Compose which items are the same
) { post ->
PostCard(
post = post,
modifier = Modifier.animateItemPlacement() // smooth insert/remove animations
)
}
}
Without a stable key:
- Adding a post at the top causes all items to rebind
- Removing a post causes visible layout jumps
WebSocket-Driven Updates
class FeedUpdateManager(
private val socket: WebSocket,
private val feedRepository: FeedRepository
) {
val updates: Flow<FeedUpdate> = socket.events
.map { event -> parseFeedEvent(event) }
.filterNotNull()
// Merge network updates with local state
fun observeFeed(): Flow<List<Post>> = merge(
feedRepository.getCachedPosts(),
updates.filterIsInstance<FeedUpdate.NewPost>().map { listOf(it.post) }
).scan(emptyList()) { accumulated, newPosts ->
// Dedup and merge: new posts go at the front, remove duplicates
val existingIds = accumulated.map { it.id }.toSet()
val deduped = newPosts.filter { it.id !in existingIds }
deduped + accumulated
}
}
Deduplication
Real-time feeds commonly send duplicates (REST response overlapping with WebSocket events):
class PostDeduplicator {
private val seenIds = LinkedHashSet<String>(200) // LRU-like with insertion order
fun deduplicate(posts: List<Post>): List<Post> {
val result = mutableListOf<Post>()
for (post in posts) {
if (seenIds.add(post.id)) { // add returns false if already present
result.add(post)
}
}
// Keep seenIds bounded
if (seenIds.size > 1000) {
seenIds.iterator().let { iter ->
repeat(200) { if (iter.hasNext()) { iter.next(); iter.remove() } }
}
}
return result
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
DiffUtil | Always use with RecyclerView list updates — avoids full rebind |
| Stable keys | key = { it.id } in LazyColumn — prevents scroll jumps and enables animations |
| Payload updates | Use getChangePayload for hot-updating counts without rebinding the whole item |
| "Show N new posts" | Don't prepend silently — show a banner and let user choose |
| Deduplication | Track seen IDs; REST + WebSocket will send overlapping content |
animateItemPlacement() | Compose modifier for smooth insert/remove animations |