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.

๐Ÿ“Œ Pattern: Feed Pagination

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.

Learn This Pattern โ†’

โœ… 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
๐Ÿ’ก Clarifying Questions to Ask
  • 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

๐Ÿ“„
Paging 3
PagingSource + RemoteMediator
๐Ÿ—„๏ธ
Room DB
Local post cache + remote keys
๐Ÿ–ผ๏ธ
Coil / Glide
Async image loading & caching
โ–ถ๏ธ
ExoPlayer
Video playback, preloading
๐Ÿ”
WorkManager
Queue offline likes for sync
๐Ÿ“ก
Retrofit + OkHttp
REST API, HTTP caching

Feed Pagination Tiers

Memory
PagingData in VM
Active page, instant render
RecyclerView binder ยท No I/O
Disk
Room DB Cache
Offline feed ยท Survives kill
RemoteMediator writes ยท PagingSource reads
Network
Feed API
Cursor pagination ยท Ranked server-side
GET /feed?after_id=&limit=20

3. High-Level Design

Architecture Overview
UI Layer FeedFragment StoriesBar ยท PostCard FeedViewModel PagingData<Post> Flow FeedRepository Pager( RemoteMediator ) Room Database posts ยท remote_keys action_queue Feed API GET /feed?after_id= POST /like ยท /comment Image Loader Coil ยท 3-tier cache Memory/Disk/CDN ExoPlayer Pool 1 player re-used Autoplay on 50% vis. WorkManager SyncActionsWorker

Why Paging 3 + RemoteMediator?

ApproachOffline?Config Change?Cursor?Complexity
Manual scroll + ViewModel list โœ—โœ— LostManualLow โ†’ 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
}
๐Ÿ“Œ Pattern: Cursor vs Offset Pagination

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.

Learn This Pattern โ†’

4. Low-Level Design

Whiteboard: Full Pipeline

End-to-End Feed Pipeline
UI / FeedFragment FeedViewModel RemoteMediator Room DB Feed API 1 onCreate โ†’ collectLatest 2 Pager(config, mediator) 3 load(REFRESH) triggered 4 Emit cached posts โšก Instant render from cache 5 GET remoteKeys (cursor) 6 GET /feed?after_id=โ€ฆ 7 writeTx: posts + keys 8 PagingSource invalidated 9 PagingData emitted 10 RecyclerView renders 11 โ€” Prefetch Paging 3 auto-triggers APPEND when 5 items from bottom

Flow 1: Initial Feed Load (Cold Start)

Flow 1 โ€” Cold Start: RecyclerView shows stale cache first, then fresh data arrives
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)

Flow 2 โ€” Infinite Scroll: Paging 3 auto-triggers next page 5 items before end
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

Flow 3 โ€” Optimistic Like: UI reacts instantly; server confirms; error rolls back
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

๐Ÿ“Œ Pattern: ConcatAdapter

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

๐Ÿ”ต Mid-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
๐ŸŸข Senior
  • 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
๐ŸŸฃ Staff+
  • 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

1. Why use cursor-based pagination over offset pagination for a social feed?
Easyโ–พ

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.

2. Explain the role of RemoteMediator vs PagingSource in Paging 3.
Mediumโ–พ

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.

3. How does Paging 3 know when to fetch the next page?
Easyโ–พ

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.

4. How do you implement pull-to-refresh with Paging 3?
Easyโ–พ

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.

5. What is an optimistic UI update and why is it important for the Like button?
Mediumโ–พ

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.

6. How do you prevent RecyclerView from triggering redundant API calls during fast scroll?
Mediumโ–พ

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.

7. How would you implement video autoplay that pauses when the video scrolls off screen?
Hardโ–พ

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.

8. How does DiffUtil work in a Paging adapter and why is it important?
Mediumโ–พ

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).

9. How do you handle the "end of feed" state with a loading footer?
Easyโ–พ

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.

10. How would you implement offline mode for the feed?
Mediumโ–พ

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.

11. Why use ConcatAdapter for Stories + Feed, instead of a nested RecyclerView?
Hardโ–พ

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.

12. How do you pre-fetch images for the next page before the user scrolls to it?
Hardโ–พ

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.

13. What happens to the feed scroll position on a screen rotation?
Mediumโ–พ

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.

14. How would you implement new-post notifications that update the feed in real time?
Hardโ–พ

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.

15. How do you cancel image loads when a ViewHolder is recycled?
Mediumโ–พ

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.

16. How do you handle the case where a user likes a post, the app is killed, and reopened?
Hardโ–พ

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.

17. How would you implement stories with auto-advance and progress bars?
Hardโ–พ

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).

18. How do you test the RemoteMediator in isolation?
Hardโ–พ

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.

19. How would you prevent showing stale like counts after the user refreshes the feed?
Mediumโ–พ

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.

20. How would you handle mixed content types (images, videos, carousels, ads) in one feed?
Hardโ–พ

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).