androidengineers.Book a session

Designing a News Feed/Social App

Realtime Updates & Diffing

article20 minHard

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

PatternRule
DiffUtilAlways use with RecyclerView list updates — avoids full rebind
Stable keyskey = { it.id } in LazyColumn — prevents scroll jumps and enables animations
Payload updatesUse 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
DeduplicationTrack seen IDs; REST + WebSocket will send overlapping content
animateItemPlacement()Compose modifier for smooth insert/remove animations

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Realtime Updates & Diffing | Android System Design | Android Engineers