androidengineers.Book a session

Designing a News Feed/Social App

Timeline/Pagination Architecture

article25 minHard

A social media timeline is one of the most demanding feeds to build: it must load new posts at the top, paginate older posts at the bottom, support pull-to-refresh, show local optimistic updates, and remain fast with thousands of items.

Paging 3: The Core Tool

Paging 3 is Jetpack's pagination library. It defines a PagingSource that loads pages of data and exposes a PagingData<T> stream that LazyColumn consumes directly.

// PagingSource: loads one page at a time
class PostPagingSource(
    private val api: FeedApi,
    private val dao: PostDao
) : PagingSource<String, Post>() {  // String = cursor key

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

    override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> = try {
        val cursor = params.key  // null = first page
        val response = api.getTimeline(cursor = cursor, limit = params.loadSize)

        LoadResult.Page(
            data = response.posts,
            prevKey = null,         // feed doesn't load newer via paging — use pull-to-refresh
            nextKey = response.nextCursor  // null = no more pages
        )
    } catch (e: IOException) {
        LoadResult.Error(e)
    } catch (e: HttpException) {
        LoadResult.Error(e)
    }
}

Repository: Offline-First with RemoteMediator

RemoteMediator coordinates network + Room database — load from network, cache to Room, serve Room to the UI:

@OptIn(ExperimentalPagingApi::class)
class PostRemoteMediator(
    private val api: FeedApi,
    private val db: AppDatabase
) : RemoteMediator<Int, PostEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PostEntity>
    ): MediatorResult {
        val page = when (loadType) {
            LoadType.REFRESH -> 1
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> {
                val lastItem = state.lastItemOrNull()
                    ?: return MediatorResult.Success(endOfPaginationReached = false)
                db.remoteKeyDao().getKey(lastItem.id)?.nextPage
                    ?: return MediatorResult.Success(endOfPaginationReached = true)
            }
        }

        return try {
            val response = api.getTimeline(page = page, limit = state.config.pageSize)

            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    db.postDao().clearAll()
                    db.remoteKeyDao().clearAll()
                }
                db.postDao().insertAll(response.posts.map { it.toEntity() })
                db.remoteKeyDao().insertAll(response.posts.map {
                    RemoteKey(postId = it.id, nextPage = page + 1)
                })
            }

            MediatorResult.Success(endOfPaginationReached = response.posts.isEmpty())
        } catch (e: IOException) {
            MediatorResult.Error(e)
        }
    }
}

// Repository
class FeedRepository(private val db: AppDatabase, private val api: FeedApi) {
    @OptIn(ExperimentalPagingApi::class)
    fun getTimeline(): Flow<PagingData<Post>> = Pager(
        config = PagingConfig(pageSize = 20, enablePlaceholders = false),
        remoteMediator = PostRemoteMediator(api, db),
        pagingSourceFactory = { db.postDao().pagingSource() }  // Room-backed source
    ).flow
}

ViewModel and UI

class FeedViewModel(private val repository: FeedRepository) : ViewModel() {
    val posts: StateFlow<PagingData<Post>> = repository.getTimeline()
        .cachedIn(viewModelScope)  // ← critical: cache across recompositions
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PagingData.empty())
}

@Composable
fun FeedScreen(viewModel: FeedViewModel = viewModel()) {
    val posts = viewModel.posts.collectAsLazyPagingItems()
    val pullRefreshState = rememberPullRefreshState(
        refreshing = posts.loadState.refresh is LoadState.Loading,
        onRefresh = { posts.refresh() }
    )

    Box(Modifier.pullRefresh(pullRefreshState)) {
        LazyColumn {
            items(
                count = posts.itemCount,
                key = posts.itemKey { it.id }  // stable keys prevent flicker on refresh
            ) { index ->
                val post = posts[index]
                if (post != null) {
                    PostCard(post = post)
                } else {
                    PostPlaceholder()  // placeholder while loading
                }
            }

            // Loading indicator at bottom
            when (val appendState = posts.loadState.append) {
                is LoadState.Loading -> item { CircularProgressIndicator() }
                is LoadState.Error -> item {
                    RetryButton(onRetry = { posts.retry() })
                }
                else -> {}
            }
        }

        PullRefreshIndicator(
            refreshing = posts.loadState.refresh is LoadState.Loading,
            state = pullRefreshState,
            modifier = Modifier.align(Alignment.TopCenter)
        )
    }
}

Key Takeaways

ConceptRule
PagingSourceLoad-on-demand source; return LoadResult.Page with next key
RemoteMediatorSync network → Room; UI always reads from Room
cachedIn(viewModelScope)Cache PagingData in ViewModel — survive recomposition
collectAsLazyPagingItems()Bridge PagingData to Compose LazyColumn
key = posts.itemKey { it.id }Stable keys prevent RecyclerView / LazyColumn flicker on refresh
posts.refresh()Pull-to-refresh resets to page 1
posts.retry()Retry last failed page load

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Timeline/Pagination Architecture | Android System Design | Android Engineers