androidengineers.Book a session

Designing a News Feed/Social App

Exercise: Feed with Paging 3 + Cache

exercise60 minHard

Build a fully offline-first social feed using Paging 3 with Room as the local source of truth, pull-to-refresh, and seamless new-post notifications.

System Design

UI (LazyColumn + collectAsLazyPagingItems)
    ↓
ViewModel (PagingData<Post> cached in scope)
    ↓
Repository (Pager with RemoteMediator)
    ↓
RemoteMediator ─── API (network)
    ↓                 ↓
PostDao (Room)  ←────┘  (cache write)
    ↓
PagingSource (Room-backed; UI reads from here)

Step 1: Database Schema

@Entity(tableName = "posts")
data class PostEntity(
    @PrimaryKey val id: String,
    val authorId: String,
    val body: String,
    val imageUrl: String?,
    val likeCount: Int,
    val commentCount: Int,
    val createdAt: Long,
    val page: Int  // which page this came from
)

@Entity(tableName = "remote_keys")
data class RemoteKey(
    @PrimaryKey val postId: String,
    val nextPage: Int?
)

@Dao
interface PostDao {
    @Query("SELECT * FROM posts ORDER BY created_at DESC")
    fun pagingSource(): PagingSource<Int, PostEntity>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(posts: List<PostEntity>)

    @Query("DELETE FROM posts")
    suspend fun clearAll()

    @Query("SELECT COUNT(*) FROM posts")
    suspend fun count(): Int
}

Step 2: RemoteMediator

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

    override suspend fun initialize(): InitializeAction {
        // Refresh if cache is empty or older than 30 minutes
        val lastUpdate = db.postDao().getLastUpdatedTime()
        val cacheAge = System.currentTimeMillis() - (lastUpdate ?: 0)
        return if (cacheAge < 30 * 60 * 1000) {
            InitializeAction.SKIP_INITIAL_REFRESH  // use cache
        } else {
            InitializeAction.LAUNCH_INITIAL_REFRESH  // refresh from network
        }
    }

    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.getPosts(page = page, limit = state.config.pageSize)

            db.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    db.postDao().clearAll()
                    db.remoteKeyDao().clearAll()
                }

                val entities = response.posts.map { it.toEntity(page = page) }
                db.postDao().insertAll(entities)
                db.remoteKeyDao().insertAll(
                    entities.map { RemoteKey(it.id, if (response.hasNextPage) page + 1 else null) }
                )
            }

            MediatorResult.Success(endOfPaginationReached = !response.hasNextPage)
        } catch (e: IOException) {
            MediatorResult.Error(e)
        } catch (e: HttpException) {
            MediatorResult.Error(e)
        }
    }
}

Step 3: Repository and ViewModel

class FeedRepository @Inject constructor(
    private val db: AppDatabase,
    private val api: FeedApi
) {
    @OptIn(ExperimentalPagingApi::class)
    fun getFeed(): Flow<PagingData<Post>> = Pager(
        config = PagingConfig(
            pageSize = 20,
            prefetchDistance = 5,
            enablePlaceholders = false,
            initialLoadSize = 40
        ),
        remoteMediator = PostRemoteMediator(api, db),
        pagingSourceFactory = { db.postDao().pagingSource() }
    ).flow.map { pagingData ->
        pagingData.map { it.toDomain() }
    }
}

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

    val posts: StateFlow<PagingData<Post>> = repository.getFeed()
        .cachedIn(viewModelScope)  // ← survive recomposition and back navigation
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PagingData.empty())
}

Step 4: Compose UI

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

    Box(Modifier.fillMaxSize().pullRefresh(pullRefreshState)) {
        LazyColumn {
            items(
                count = posts.itemCount,
                key = posts.itemKey { it.id }
            ) { index ->
                posts[index]?.let { post ->
                    PostCard(
                        post = post,
                        modifier = Modifier.animateItemPlacement()
                    )
                } ?: PostPlaceholder()
            }

            // Error / loading state at bottom
            with(posts.loadState.append) {
                when {
                    this is LoadState.Loading -> item { PostLoadingIndicator() }
                    this is LoadState.Error -> item {
                        ErrorRetryItem(onRetry = { posts.retry() })
                    }
                }
            }

            // Empty state
            if (posts.itemCount == 0 && posts.loadState.refresh !is LoadState.Loading) {
                item {
                    Box(Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
                        Text("No posts yet")
                    }
                }
            }
        }

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

Verification Checklist

[ ] Initial load: shows posts from cache immediately (< 100ms) while network refreshes
[ ] Scroll to bottom: loads next page automatically (no button needed)
[ ] Pull to refresh: clears cache, loads fresh data, scrolls to top
[ ] Airplane mode: shows cached posts; bottom shows "No internet" error
[ ] Retry on error: error item has a "Retry" button that resumes pagination
[ ] No duplicate posts: same post ID appears only once after refresh
[ ] Scroll position preserved on back navigation (cachedIn ensures this)

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Feed with Paging 3 + Cache | Android System Design | Android Engineers