androidengineers.Book a session

Case Study: Design Instagram

Feed Ranking & Pagination

article25 minHard

A social feed is a ranked, paginated list of posts. The ranking happens server-side; the client's job is efficient pagination, local caching, and smooth insertion of new posts.

Cursor-Based Pagination

Instagram and Twitter use cursor-based pagination instead of page numbers — cursors are stable even when new posts arrive.

data class FeedPage(
    val posts: List<Post>,
    val nextCursor: String?,   // null = no more pages
    val prevCursor: String?    // for refreshing new posts at top
)

interface FeedApi {
    suspend fun getFeed(cursor: String? = null, limit: Int = 20): FeedPage
    suspend fun getNewPosts(afterCursor: String): FeedPage  // posts newer than cursor
}

Paging 3 with Cursor

class FeedPagingSource(private val api: FeedApi) : PagingSource<String, Post>() {

    override fun getRefreshKey(state: PagingState<String, Post>): String? = null

    override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> {
        return try {
            val page = api.getFeed(cursor = params.key, limit = params.loadSize)
            LoadResult.Page(
                data = page.posts,
                prevKey = null,      // Paging 3 handles new posts via prepend
                nextKey = page.nextCursor
            )
        } catch (e: IOException) {
            LoadResult.Error(e)
        } catch (e: HttpException) {
            LoadResult.Error(e)
        }
    }
}

class FeedRepository(private val api: FeedApi, private val db: AppDatabase) {
    val feedPager: Flow<PagingData<Post>> = Pager(
        config = PagingConfig(pageSize = 20, prefetchDistance = 5),
        pagingSourceFactory = { FeedPagingSource(api) }
    ).flow.cachedIn(GlobalScope)  // cache across recompositions
}

Server-Side Ranking Signals

The server ranks posts by a score. As an Android engineer you need to understand these to make informed decisions about what to cache and when to refresh:

SignalWeightEffect
FreshnessHighNewer posts ranked higher
EngagementHighPosts with more likes/comments
Author relationshipHighPosts from close friends > strangers
User interestMediumML model predicts interest from past behavior
DiversityMediumAvoid 10 posts from same author in a row

"Show New Posts" Banner

When new posts arrive while the user is scrolling, don't jump the list — show a banner:

@HiltViewModel
class FeedViewModel @Inject constructor(
    repository: FeedRepository,
    private val api: FeedApi
) : ViewModel() {

    val feedPagingData = repository.feedPager

    private var topCursor: String? = null
    private val _newPostCount = MutableStateFlow(0)
    val newPostCount: StateFlow<Int> = _newPostCount

    init {
        startPollingForNewPosts()
    }

    private fun startPollingForNewPosts() = viewModelScope.launch {
        while (isActive) {
            delay(30_000)  // poll every 30s
            topCursor?.let { cursor ->
                try {
                    val newPosts = api.getNewPosts(afterCursor = cursor)
                    if (newPosts.posts.isNotEmpty()) {
                        _newPostCount.value = newPosts.posts.size
                    }
                } catch (e: IOException) {
                    // ignore; try again next poll
                }
            }
        }
    }

    fun onFeedScrolledToTop(cursor: String) {
        topCursor = cursor
        _newPostCount.value = 0
    }

    fun onShowNewPostsTapped() {
        viewModelScope.launch { feedPagingData.collectLatest { /* invalidate Pager */ } }
    }
}

// In Compose
val newPostCount by viewModel.newPostCount.collectAsStateWithLifecycle()
if (newPostCount > 0) {
    FloatingActionButton(onClick = viewModel::onShowNewPostsTapped) {
        Text("$newPostCount new posts")
    }
}

Room-Cached Feed (RemoteMediator)

class FeedRemoteMediator(
    private val api: FeedApi,
    private val db: AppDatabase
) : RemoteMediator<Int, PostEntity>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, PostEntity>): MediatorResult {
        val cursor = when (loadType) {
            LoadType.REFRESH -> null
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> db.remoteKeyDao().getLastKey() ?: return MediatorResult.Success(true)
        }

        return try {
            val page = api.getFeed(cursor = cursor, limit = state.config.pageSize)

            db.withTransaction {
                if (loadType == LoadType.REFRESH) db.postDao().clearAll()
                db.postDao().insertAll(page.posts.map { it.toEntity() })
                page.nextCursor?.let { db.remoteKeyDao().save(it) }
            }

            MediatorResult.Success(endOfPaginationReached = page.nextCursor == null)
        } catch (e: IOException) {
            MediatorResult.Error(e)
        }
    }
}

Key Takeaways

PatternRule
Cursor paginationStable even when new posts arrive; use instead of page numbers
cachedInCache Pager flow across ViewModel instances to survive config change
New post bannerDon't jump the scroll; show count + let user tap to refresh
RemoteMediatorCache feed in Room; show cached on launch; sync in background
Poll, not WebSocket30s polling for "new posts" count; save WebSocket for real-time (messaging)
Prefetch distance5 items ahead = silently load next page before user reaches bottom

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Feed Ranking & Pagination | Android System Design | Android Engineers