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:
| Signal | Weight | Effect |
|---|---|---|
| Freshness | High | Newer posts ranked higher |
| Engagement | High | Posts with more likes/comments |
| Author relationship | High | Posts from close friends > strangers |
| User interest | Medium | ML model predicts interest from past behavior |
| Diversity | Medium | Avoid 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
| Pattern | Rule |
|---|---|
| Cursor pagination | Stable even when new posts arrive; use instead of page numbers |
cachedIn | Cache Pager flow across ViewModel instances to survive config change |
| New post banner | Don't jump the scroll; show count + let user tap to refresh |
| RemoteMediator | Cache feed in Room; show cached on launch; sync in background |
| Poll, not WebSocket | 30s polling for "new posts" count; save WebSocket for real-time (messaging) |
| Prefetch distance | 5 items ahead = silently load next page before user reaches bottom |