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)