Design an Instagram-Style Feed
1. Understanding the Problem
The interviewer asks you to design the Android client for an Instagram-style social feed โ infinite scroll of posts (images, videos, captions, likes, comments). This is a deceptively rich problem: it touches Paging, offline-first caching, image loading, video autoplay, and optimistic UI updates.
Social feeds are the canonical use-case for cursor-based pagination. Offset pagination breaks when new posts are inserted mid-scroll; a cursor (e.g., after_id or timestamp) gives a stable position regardless of server inserts.
โ Functional Requirements
- Display an infinite, paginated feed of posts
- Each post: image/video, avatar, username, caption, likes, comments count
- Double-tap or heart icon to like/unlike (optimistic)
- Stories bar at the top (horizontal scroll)
- Video posts autoplay when โฅ50% visible, pause on scroll away
- Offline: show last cached feed when no internet
- Pull-to-refresh for latest posts
โ๏ธ Non-Functional Requirements
- Startup: Feed visible <2 s cold start
- Scroll: 60 fps, zero jank on RecyclerView
- Data: Paginate in chunks of 20 posts
- Prefetch: Next page loads before user reaches end
- Images: Decode at display size, pool bitmaps
- Battery: No redundant API calls; de-duplicate requests
- Video: Preload next video when on Wi-Fi
- Is the feed ranked/personalized server-side or chronological?
- Should we support Stories, Reels, or just static posts?
- Is video required? Autoplay with sound or muted?
- Is offline read-only acceptable, or do likes/comments queue offline?
2. The Set Up
Core Components
Feed Pagination Tiers
3. High-Level Design
Why Paging 3 + RemoteMediator?
| Approach | Offline? | Config Change? | Cursor? | Complexity |
|---|---|---|---|---|
| Manual scroll + ViewModel list | โ | โ Lost | Manual | Low โ grows complex |
| PagingSource only (network) | โ | โ | โ | Medium |
| ๐ PagingSource + RemoteMediator (Room) | โ | โ | โ | Medium-High |
Feed API Contract
// Cursor-based pagination โ stable across inserts
GET /feed?after_id=1234&limit=20
// Response
{
"posts": [ { "id": 1235, "imageUrl": "...", "videoUrl": "...",
"author": { "id": 42, "username": "alice", "avatarUrl": "..." },
"caption": "Sunsets ๐
", "likeCount": 1203,
"isLiked": false, "commentCount": 47, "mediaType": "IMAGE"
}, ... ],
"nextCursor": "1254",
"hasMore": true
}
Use cursor-based pagination for feeds. Offset (page=2) drifts when new posts are inserted โ the user sees duplicate or skipped items. A stable cursor anchors position regardless of server mutations.
4. Low-Level Design
Whiteboard: Full Pipeline
Flow 1: Initial Feed Load (Cold Start)
| FeedFragment | FeedViewModel | RemoteMediator | Room DB | API |
|---|---|---|---|---|
1Fragment.onViewCreated โ adapter.submitData(pagingData) |
||||
| 2Pager(RemoteMediator, RoomPagingSource).flow exposed as StateFlow | ||||
3Paging calls initialize() โ returns LAUNCH_INITIAL_REFRESH |
||||
| 4RoomPagingSource emits cached posts (may be empty on first run) | ||||
| 5RecyclerView shows cached items while spinner runs | ||||
6load(REFRESH): reads remote_keys for cursor; if null โ first load |
||||
| 7GET /feed?limit=20 (no cursor on REFRESH) | ||||
| 8Transaction: DELETE old posts, INSERT new posts + remote_keys in one tx โ atomic | ||||
| 9Room emits invalidation โ PagingSource re-loads | ||||
| 10DiffUtil animates new items in. User sees fresh feed. |
Flow 2: Infinite Scroll (APPEND)
| RecyclerView | Paging Engine | RemoteMediator | Room DB | API |
|---|---|---|---|---|
| 1User scrolls; reaches prefetchDistance threshold (5 items from end) | ||||
2Paging calls load(LoadType.APPEND) |
||||
3Reads remote_keys for last item's nextCursor |
||||
| 4GET /feed?after_id=<cursor>&limit=20 | ||||
| 5INSERT new posts + update remote_keys (no delete on APPEND) | ||||
| 6New items smoothly appended; no flicker, no full reload |
Flow 3: Optimistic Like / Unlike
| PostCard / UI | FeedViewModel | Room DB | API / WorkManager |
|---|---|---|---|
| 1User double-taps post โ heart animation fires | |||
2toggleLike(postId) โ optimistic local update |
|||
| 3UPDATE posts SET isLiked=true, likeCount+=1 WHERE id=postId | |||
| 4RecyclerView re-binds item immediately via DiffUtil (no full reload) | |||
| 5POST /like {postId} in background coroutine | |||
| 6a โServer success โ no-op, local is already correct | |||
| 6b โServer error โ rollback: UPDATE isLiked=false, likeCount-=1 | |||
| 7If offline: enqueue SyncLikeWorker (WorkManager), retries when online |
Key Code: RemoteMediator
class FeedRemoteMediator(
private val api: FeedApi,
private val db: FeedDatabase
) : RemoteMediator<Int, PostEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, PostEntity>
): MediatorResult {
val cursor = when (loadType) {
LoadType.REFRESH -> null // always reload from top
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> {
val last = state.lastItemOrNull()
?: return MediatorResult.Success(endOfPaginationReached = true)
db.remoteKeysDao().remoteKeyById(last.id)?.nextCursor
}
}
return try {
val response = api.getFeed(afterId = cursor, limit = state.config.pageSize)
val posts = response.posts
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.postsDao().clearAll()
db.remoteKeysDao().clearAll()
}
val keys = posts.map { post ->
RemoteKey(postId = post.id, nextCursor = response.nextCursor)
}
db.remoteKeysDao().insertAll(keys)
db.postsDao().insertAll(posts.map { it.toEntity() })
}
MediatorResult.Success(endOfPaginationReached = !response.hasMore)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
Key Code: Video Autoplay with ExoPlayer
// One shared ExoPlayer reused across RecyclerView items
class VideoAutoplayManager(context: Context) {
private val player = ExoPlayer.Builder(context).build()
private var currentViewHolder: VideoViewHolder? = null
// Called from RecyclerView.OnScrollListener
fun onScrollSettled(recyclerView: RecyclerView) {
val mostVisible = findMostVisibleVideoHolder(recyclerView)
if (mostVisible?.postId == currentViewHolder?.postId) return
// Detach from old item
currentViewHolder?.playerView?.player = null
player.stop()
// Attach to new item
mostVisible?.let { vh ->
vh.playerView.player = player
player.setMediaItem(MediaItem.fromUri(vh.videoUrl))
player.prepare()
player.play()
currentViewHolder = vh
}
}
private fun findMostVisibleVideoHolder(rv: RecyclerView): VideoViewHolder? {
val lm = rv.layoutManager as LinearLayoutManager
val first = lm.findFirstVisibleItemPosition()
val last = lm.findLastVisibleItemPosition()
var best: VideoViewHolder? = null; var bestArea = 0
(first..last).forEach { i ->
val vh = rv.findViewHolderForAdapterPosition(i) as? VideoViewHolder ?: return@forEach
val rect = Rect()
vh.itemView.getGlobalVisibleRect(rect)
val area = rect.width() * rect.height()
if (area > bestArea && visibilityPercent(vh) >= 50) { best = vh; bestArea = area }
}
return best
}
}
5. Potential Deep Dives
Image Pre-fetching
Coil's ImageLoader can be used headlessly to pre-fetch images before the user scrolls to them. In the RemoteMediator, after inserting posts into Room, issue pre-fetch requests for the next 5 image URLs:
// Warm the disk cache for next page images
posts.take(5).forEach { post ->
val req = ImageRequest.Builder(context)
.data(post.imageUrl)
.memoryCachePolicy(CachePolicy.DISABLED) // disk only
.diskCachePolicy(CachePolicy.ENABLED)
.build()
imageLoader.enqueue(req)
}
RecyclerView Performance
- setHasFixedSize(true) โ skips full layout pass when items change size
- DiffUtil (built into PagingDataAdapter) โ only rebinds changed items
- setRecycledViewPool โ share pool between Stories RecyclerView (horizontal) and feed
- View.setTag(R.id.image_request, request) โ cancel Coil request in
onViewRecycledto avoid stale loads - Avoid nested RecyclerViews โ use ConcatAdapter to merge Stories + Feed in one list
ConcatAdapter merges multiple adapters (StoriesAdapter + FeedAdapter) into a single RecyclerView, eliminating nested scroll jank and allowing the Paging engine to manage the full list.
Learn This Pattern โOffline Like Queue
If the user likes a post while offline, enqueue a SyncLikeWorker with Constraints(NetworkType.CONNECTED). WorkManager persists the work across process death and retries with exponential backoff:
class SyncLikeWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val postId = inputData.getString("POST_ID") ?: return Result.failure()
val like = inputData.getBoolean("IS_LIKE", true)
return try {
if (like) api.like(postId) else api.unlike(postId)
Result.success()
} catch (e: Exception) { Result.retry() }
}
}
Pull-to-Refresh
Observe LoadState.Refresh from the PagingDataAdapter. Show SwipeRefreshLayout spinner while LoadState.Loading, hide on NotLoading. Call adapter.refresh() on swipe โ Paging 3 re-triggers RemoteMediator.load(REFRESH) automatically.
Stories Circular Crop + Progress
Stories avatars use a circular crop transformation in Coil. The unread ring is drawn via a custom Drawable with a GradientDrawable ring. Story progress bars are a horizontal LinearProgressIndicator per segment, advanced by a CountDownTimer that auto-advances the ViewPager2.
6. What is Expected at Each Level
- Knows Paging 3 exists and why it's used
- Implements RecyclerView + ViewHolder correctly
- Loads images with Coil/Glide, shows placeholder
- Basic Room cache with manual pagination
- Handles config change in ViewModel
- Knows DiffUtil for efficient updates
- Full RemoteMediator pattern โ Room as SSoT
- Cursor-based pagination vs offset trade-offs
- Optimistic like with rollback on failure
- ExoPlayer single-player pattern for video
- WorkManager for offline action queue
- Image pre-fetching strategy
- ConcatAdapter for stories + feed
- Feed refresh strategy without losing scroll position
- Incremental invalidation (only updated posts re-bind)
- Predictive scroll-ahead prefetch based on velocity
- ExoPlayer bandwidth estimation for adaptive bitrate
- Multiple media types in one list (ConcatAdapter + viewType)
- A/B testing feed ranking signals client-side
- Memory pressure โ trim Stories cache first
7. Interview Questions
Offset pagination (page=2&size=20) uses a numeric offset into the dataset. If new posts are inserted between page fetches, items shift โ causing duplicates or skipped posts. Cursor-based pagination anchors to a specific item ID or timestamp, so even with concurrent inserts, the next page starts from the exact item after the cursor. For a social feed with constant new content this is essential. The trade-off: cursors can't jump to arbitrary pages (no "go to page 5"), but feeds scroll linearly so this is fine.
PagingSource is the single source of truth for paged data โ it reads from Room and emits pages to the UI. It knows nothing about the network. RemoteMediator is a bridge that reacts to boundary events (need more data) by hitting the API, writing results into Room, and letting PagingSource naturally pick up the change via Room's reactive invalidation. This separation keeps PagingSource simple (just Room queries) and RemoteMediator focused on network-to-Room sync.
The Paging engine tracks how many items the user has consumed. When the number of unconsumed items ahead drops below prefetchDistance (default = page size), it triggers a APPEND load. You can configure PagingConfig(pageSize=20, prefetchDistance=5) so the next API call fires when the user is 5 items from the end โ giving enough lead time for the network request to complete before they scroll there.
Call adapter.refresh() from the SwipeRefreshLayout listener. Paging 3 re-triggers RemoteMediator.load(LoadType.REFRESH), which clears old Room data and inserts fresh posts atomically. Observe adapter.loadStateFlow and bind the refresh indicator to LoadState.Refresh is Loading. Hide it when is NotLoading. Handle is Error to show a Snackbar.
An optimistic update applies the expected UI change immediately without waiting for server confirmation. For likes, this means toggling isLiked and incrementing likeCount in Room the moment the user taps โ the heart animation plays with zero latency. The API call runs in the background. On success: no-op. On failure: roll back Room to the previous state and show an error. This pattern is critical for perceived responsiveness in social apps โ users expect instant feedback on interactions.
Paging 3 automatically de-duplicates: it only triggers one APPEND at a time, queuing subsequent requests. Additionally: (1) use distinctUntilChanged() on the PagingData flow so identical pages don't cause re-emission; (2) Room's PagingSource emits only when the underlying data actually changes; (3) DiffUtil in PagingDataAdapter ensures only changed items re-bind, not the entire list.
Use a single ExoPlayer instance (expensive to create) shared across items. Attach a RecyclerView.OnScrollListener that, when scroll settles, finds the video ViewHolder with the largest visible area (โฅ50% of item height). Detach the player from the old ViewHolder, set playerView.player = null, stop playback. Attach to the new ViewHolder, set MediaItem, call prepare() and play(). Release the player in Fragment.onDestroyView() to avoid leaks.
PagingDataAdapter uses a DiffUtil.ItemCallback that you provide. When a new PagingData page arrives, DiffUtil computes the minimum set of changes (insertions, removals, moves, changes) between old and new lists on a background thread. Only the changed ViewHolders re-bind โ the rest stay as-is. This means appending 20 new items animates a smooth insertion at the bottom without touching the visible items or causing scroll jitter. You implement areItemsTheSame (compare IDs) and areContentsTheSame (compare all fields).
Use a LoadStateAdapter and attach it via adapter.withLoadStateFooter(LoadStateAdapter). This adapter automatically shows a loading spinner while APPEND is loading, a retry button on error, and hides itself when there's no active load. For end-of-feed, RemoteMediator returns MediatorResult.Success(endOfPaginationReached=true) and Paging 3 stops triggering APPEND โ the footer simply disappears.
The RemoteMediator pattern gives offline support for free: Room is the single source of truth. On no internet, RemoteMediator.load() throws an IOException and returns MediatorResult.Error, but PagingSource still reads from Room โ the user sees the cached feed. Show a "Viewing offline" banner by observing network connectivity. Likes while offline are queued via WorkManager with NetworkType.CONNECTED constraint โ they sync automatically when connectivity returns.
Nested RecyclerView (horizontal stories inside vertical feed) breaks the outer RecyclerView's recycling: the stories row is treated as a single ViewHolder and never properly recycled. It also causes nested scroll conflicts, poor memory usage, and prevents Paging 3 from managing a unified list. ConcatAdapter merges StoriesAdapter and PagingDataAdapter into one flat RecyclerView with a single scroll. The stories row is just item type 0; posts are item type 1. RecycledViewPool is shared so ViewHolders recycle across the merged list.
After RemoteMediator inserts the next page's posts into Room, fire headless Coil requests for those image URLs with memoryCachePolicy = DISABLED and diskCachePolicy = ENABLED. This downloads and decodes the images into the disk cache without occupying memory. When RecyclerView binds those ViewHolders, Coil finds the images in the disk cache (5-50ms) rather than hitting the network. Limit pre-fetch to Wi-Fi only using ConnectivityManager to avoid wasting mobile data.
The PagingData Flow lives in the ViewModel, which survives config changes. The Fragment is recreated and re-subscribes to the same Flow โ no API call is re-issued. RecyclerView's LayoutManager saves and restores scroll position via onSaveInstanceState() / onRestoreInstanceState() automatically. The user sees the same scroll position after rotation. Videos re-attach the shared ExoPlayer instance to the newly created PlayerView after re-binding.
Two approaches: (1) FCM Push โ server sends a data push when new posts are available. The app triggers adapter.refresh() only if the user is at the top (scroll position = 0). Otherwise, show a "New Posts" chip at the top. Tap โ scroll to top โ refresh. (2) WebSocket / SSE โ maintain a live connection; server pushes new post IDs. The app prefetches those posts in the background and shows the chip. Option 1 is simpler and sufficient for most interview contexts.
Coil automatically cancels requests bound via imageView.load(url) when the ImageView is detached (which happens on recycle). If you use the Coil ImageLoader manually, store the Disposable in ViewHolder.itemView.setTag(R.id.coil_request, disposable) and call disposable.cancel() in Adapter.onViewRecycled(). Glide's lifecycle-aware RequestManager handles this automatically when bound to the Fragment lifecycle.
WorkManager persists enqueued work to its own database (backed by Room). Even if the process is killed, WorkManager re-schedules SyncLikeWorker on next app launch. The local Room posts table already has the optimistic update (isLiked=true), so the UI shows the correct state immediately. When WorkManager runs the sync and the server confirms, no further action is needed. If the sync fails permanently (after max retries), the worker returns Result.failure() and we rollback Room + show a notification.
Stories are shown in a full-screen Activity with a ViewPager2 (one Fragment per user's story set). Progress is a row of LinearProgressIndicators, one per story. A CountDownTimer(storyDuration, tickInterval) updates the active bar each tick. On timer finish: viewPager.setCurrentItem(current+1). On swipe: cancel timer, start new one. Image stories use duration=5000ms; video stories sync the timer to ExoPlayer's actual duration. Pause timer on long-press (common UX pattern).
Use Paging 3's TestPager and an in-memory Room database (Room.inMemoryDatabaseBuilder). Mock the API with a fake implementation. Call mediator.load(LoadType.REFRESH, pager.asPagingSourceFactory()...) directly and assert: (1) MediatorResult.Success returned, (2) Room contains the expected posts, (3) remote_keys table has the correct next cursor. Test the error path by having the fake API throw an IOException and assert MediatorResult.Error is returned and Room is unchanged.
On REFRESH, RemoteMediator clears Room and inserts fresh server data in a single transaction. The fresh data contains server-authoritative likeCount and isLiked values. However, if a pending like is in the WorkManager queue (not yet synced), the server's isLiked=false would incorrectly show the un-liked state. Fix: before writing fresh posts, check the action_queue table for pending likes and merge them โ overriding server isLiked with the local optimistic state for those posts.
Use a sealed class FeedItem with subtypes: ImagePost, VideoPost, CarouselPost, AdPost. In PagingDataAdapter, override getItemViewType() returning distinct integers per type. Create separate ViewHolder classes and inflate different layouts in onCreateViewHolder(). Room stores all types in one posts table with a mediaType column; a PostTypeConverter maps the enum. Server ads are injected at positions defined by the API response โ they have a mediaType=AD field and a different layout (no like/comment row, has "Sponsored" label).