Design an Instagram-Style Feed
A deep-dive Android system design breakdown of an infinite social feed — covering Paging 3, offline-first architecture, video autoplay, optimistic UI, and every edge case interviewers probe at senior and staff level.
Start every system design interview by nailing down requirements. For a feed, the deceptively rich scope means candidates who don't scope clearly run out of time before reaching the interesting parts. State your top 3 core requirements explicitly and mark everything else below the line.
- Display an infinite, paginated feed of posts (images, videos, captions, likes, comment count)
- Users can like/unlike posts — reaction must feel instant with no waiting for a server round-trip
- Feed must be usable offline — show the last cached feed when there is no internet
- Stories bar (mention it, note you'd handle it with ConcatAdapter)
- Comments — separate screen, separate API
- Video Reels — same ExoPlayer pattern, different API surface
- Real-time feed updates via WebSocket / SSE
- Performance: 60 fps scroll, zero jank on RecyclerView at 20+ items/page
- Cold start: Feed visible in <2 s — show cached items while fetching fresh data
- Memory: Only the visible window of posts held in memory; Bitmap pool recycled
- Battery: No polling; next page fetched only when user approaches the end
- Sub-100ms image load (disk cache gives us ~30ms)
- Video preloading on cellular (Wi-Fi only to protect data)
💡 Clarifying questions to ask: Is the feed ranked/personalised server-side or chronological? Are videos required? Should offline likes queue and sync later, or just be dropped?
The single most important design decision in this problem: Room is the single source of truth. The UI never reads from the network directly — it observes a Room Flow. The Paging 3 RemoteMediator syncs Room from the network in the background. This is what gives you offline support, config-change safety, and 60 fps scroll essentially for free.
Every data flow — network response, optimistic update, rollback — writes to Room first. The UI only ever reads from Room. This makes online and offline behaviour identical from the UI's perspective, and eliminates an entire class of state management bugs.
Three tables drive the entire feed system. The action_queue table is the one most candidates miss — it's what makes offline likes work correctly.
// Primary feed entity — the SSoT for every post card @Entity(tableName = "posts") data class PostEntity( @PrimaryKey val id: String, val authorId: String, val authorUsername: String, val authorAvatarUrl: String, val imageUrl: String?, val videoUrl: String?, val mediaType: MediaType, // IMAGE | VIDEO | CAROUSEL val caption: String, val likeCount: Int, val isLiked: Boolean, // source of truth for heart state val commentCount: Int, val createdAt: Long ) // Cursor storage — one row per post, survives APPEND without clearing @Entity(tableName = "remote_keys") data class RemoteKey( @PrimaryKey val postId: String, val nextCursor: String? // null = end of pagination ) // Offline action queue — persisted so likes survive process death @Entity(tableName = "action_queue") data class ActionQueueEntry( @PrimaryKey val id: String = UUID.randomUUID().toString(), val postId: String, val action: String, // "LIKE" | "UNLIKE" val createdAt: Long )
Use after_id or a timestamp cursor, never page=2&offset=40. Offset pagination drifts when new posts are inserted mid-scroll — the user sees duplicates or misses posts. A cursor anchors to a specific post regardless of concurrent inserts. The remote_keys table stores the cursor for each post so APPEND never needs to scan the entire posts table.
| Approach | Offline? | Config change? | Cursor? | Prefetch? |
|---|---|---|---|---|
| Manual scroll listener + ViewModel list | ✗ | ✗ lost | Manual | Manual |
| PagingSource only (network) | ✗ | ✓ | ✓ | ✓ |
| 🏆 PagingSource + RemoteMediator (Room) | ✓ | ✓ | ✓ | ✓ |
| FeedFragment | FeedViewModel | RemoteMediator | Room DB | Feed API |
|---|---|---|---|---|
| 1onViewCreated → collectLatest(pagingData) → adapter.submitData() | ||||
| 2Pager(config, FeedRemoteMediator, RoomPagingSource).flow exposed as StateFlow | ||||
| 3initialize() → returns LAUNCH_INITIAL_REFRESH; load(REFRESH) triggered | ||||
| 4RoomPagingSource emits cached posts immediately (may be empty on first launch) | ||||
| 5⚡ RecyclerView shows cached items within ~30ms. User sees content before API finishes. | ||||
| 6Reads remote_keys for cursor (null on first load) | ||||
| 7GET /feed?limit=20 (no cursor on REFRESH) | ||||
| 8withTransaction { DELETE old posts + keys; INSERT fresh posts + keys } — atomic | ||||
| 9Room emits invalidation → PagingSource re-queries | ||||
| 10DiffUtil animates new items in. User sees fresh feed. Scroll position preserved. |
| RecyclerView | Paging Engine | RemoteMediator | Room | API |
|---|---|---|---|---|
| 1User scrolls; reaches prefetchDistance threshold (5 items from end by default) | ||||
| 2Paging calls load(LoadType.APPEND) — only one APPEND at a time, queued if duplicate | ||||
| 3Reads remote_keys for the last item's nextCursor | ||||
| 4GET /feed?after_id=<cursor>&limit=20 | ||||
| 5INSERT new posts + update remote_keys (no DELETE on APPEND — keep existing items) | ||||
| 6New items smoothly appended at bottom. No flicker, no full reload of visible items. | ||||
| 7If response.hasMore=false → MediatorResult.Success(endOfPaginationReached=true) → LoadStateAdapter hides footer |
| PostCard / UI | FeedViewModel | Room DB | API / WorkManager |
|---|---|---|---|
| 1User double-taps → heart animation fires immediately | |||
| 2toggleLike(postId) → optimistic local update in coroutine | |||
| 3UPDATE posts SET isLiked=true, likeCount+=1 WHERE id=postId | |||
| 4Room emits → DiffUtil re-binds only this item. Zero full-list reload. | |||
| 5POST /like {postId} in background coroutine | |||
| 6a ✓Server success → no-op, local state already correct | |||
| 6b ✗Server error → rollback: isLiked=false, likeCount-=1. Show snackbar. | |||
| 7If offline: INSERT action_queue entry; enqueue SyncActionsWorker (NETWORK_CONNECTED constraint) |
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 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 MediatorResult.Success(endOfPaginationReached = true) } } return try { val response = api.getFeed(afterId = cursor, limit = state.config.pageSize) db.withTransaction { if (loadType == LoadType.REFRESH) { db.postsDao().clearAll() db.remoteKeysDao().clearAll() } // Merge pending offline likes — don't overwrite optimistic state val pending = db.actionQueueDao().getAllPendingPostIds().toSet() val entities = response.posts.map { it.toEntity(preserveLikeFor = pending) } db.postsDao().insertAll(entities) db.remoteKeysDao().insertAll(response.posts.map { RemoteKey(postId = it.id, nextCursor = response.nextCursor) }) } MediatorResult.Success(endOfPaginationReached = !response.hasMore) } catch (e: Exception) { MediatorResult.Error(e) } } }
// One ExoPlayer instance shared across all RecyclerView items class VideoAutoplayManager(context: Context) { private var player: ExoPlayer? = ExoPlayer.Builder(context).build() private var currentVH: VideoViewHolder? = null // Attach to RecyclerView.OnScrollListener.onScrolled fun onScrollSettled(rv: RecyclerView) { val best = findMostVisibleVideo(rv) if (best?.postId == currentVH?.postId) return currentVH?.playerView?.player = null player?.stop() best?.let { vh -> vh.playerView.player = player player?.setMediaItem(MediaItem.fromUri(vh.videoUrl)) player?.prepare() player?.play() currentVH = vh } } // Release on Fragment.onDestroyView() — avoids AudioFocus leak fun release() { currentVH?.playerView?.player = null player?.release() player = null } private fun findMostVisibleVideo(rv: RecyclerView): VideoViewHolder? { val lm = rv.layoutManager as LinearLayoutManager return (lm.findFirstVisibleItemPosition()..lm.findLastVisibleItemPosition()) .mapNotNull { rv.findViewHolderForAdapterPosition(it) as? VideoViewHolder } .filter { visiblePercent(it) >= 50 } .maxByOrNull { visiblePercent(it) } } }
This is the section that separates senior from staff candidates. Every scenario below has been reported in real Meta, Snap, and Google interviews. Know the mechanism, not just the answer.
The problem: On low-RAM devices, Android's LMK can kill your process while the user is mid-scroll. When they return, a cold start happens — they expect to see the feed immediately, not a spinner.
The fix: Room is your answer. Because all posts are in Room, the app cold-starts and renders cached items in <100ms before any API call completes. Additionally, override onTrimMemory(level) in your Application class. When level ≥ TRIM_MEMORY_MODERATE, tell Coil's ImageLoader to clear its memory cache (imageLoader.memoryCache?.clear()). At TRIM_MEMORY_COMPLETE, call videoAutoplayManager.release() — ExoPlayer holds codec buffers that can be several MB.
RecyclerView bitmap pool: Coil uses a BitmapPool that recycles bitmap allocations across item binds. On memory pressure, Coil automatically trims this pool. You can tune the pool size via ImageLoader.Builder(context).bitmapPoolingEnabled(true). Never decode images at full resolution — always pass the target ImageView size so Coil downsamples at decode time.
The problem: The user is scrolling on Wi-Fi, switches to cellular. In-flight image requests may fail; video preloading should stop immediately (data cost).
The fix: Register a ConnectivityManager.NetworkCallback in your Application. When onAvailable(network) fires, check NetworkCapabilities.hasTransport(TRANSPORT_CELLULAR). If true: (1) cancel queued image prefetch requests; (2) pause ExoPlayer's buffer-ahead by setting player.setPlaybackParameters or limiting the DefaultLoadControl.maxBufferMs; (3) OkHttp will automatically retry failed requests on the new network via its connection pool fallback.
On reconnect after full offline: WorkManager observes network availability via its CONNECTED constraint — SyncActionsWorker fires automatically. You don't need to handle this manually.
The problem: FCM push arrives: "10 new posts". If you call adapter.refresh() immediately, the RecyclerView jumps to the top — terrible UX if the user is mid-scroll.
The fix: Show a "New Posts" chip at the top of the feed (floating, not part of the list). Check the current scroll offset: val isAtTop = (layoutManager.findFirstCompletelyVisibleItemPosition() == 0). If the user is already at the top, auto-refresh. Otherwise, show the chip. When the user taps the chip, scroll to position 0 (recyclerView.smoothScrollToPosition(0)), then call adapter.refresh() in the scroll listener's onScrolled once position 0 is reached.
The chip: is a separate View overlaid above the RecyclerView, not a list item. This means it doesn't interfere with Paging's item count or DiffUtil calculations.
The problem: The user opens the app after 6 hours. The cached feed is stale. You don't want to block on the API call, but you also shouldn't show 6-hour-old content indefinitely.
The fix: Two-part approach. First, RemoteMediator.initialize() can return SKIP_INITIAL_REFRESH if the last refresh was recent (e.g., within 30 minutes) — check a timestamp stored in DataStore. Beyond the threshold, return LAUNCH_INITIAL_REFRESH to trigger a background API call while showing cached content. Second, add an OkHttp CacheControl interceptor that reads the server's Cache-Control: max-age=300 header. For content older than max-age, OkHttp automatically makes a conditional request with If-None-Match — if the server returns 304, no data transfer occurs.
The problem: User rotates the screen. Naive implementation re-fetches the entire feed and resets scroll to top.
The fix (config change): The PagingData Flow lives in the ViewModel, which survives rotation. The Fragment is recreated and re-collects the same Flow — no API call fires. RecyclerView's LinearLayoutManager automatically saves/restores scroll position via onSaveInstanceState().
The fix (process death): Room survives. On restart, RemoteMediator.initialize() checks DataStore for a recent refresh timestamp. If within the TTL, it returns SKIP_INITIAL_REFRESH and Room serves the cached feed instantly. Use SavedStateHandle in the ViewModel to persist the last visible position so you can scroll back to it on recreate.
The problem: ExoPlayer throws a PlaybackException (codec error, network error, DRM failure). The video item shows a black square and the player is in a broken state.
The fix: Add a Player.Listener: on onPlayerError(error), check the error type. For network errors (ERROR_CODE_IO_*): retry up to 3 times with 500ms delay. For codec/format errors (ERROR_CODE_DECODING_*): release the current player, recreate it (ExoPlayer.Builder(context).build()), re-attach to the ViewHolder, and show a thumbnail fallback in the PlayerView. Always call player.stop() before player.release() to avoid IllegalStateException. Release AudioFocus before recreating the player.
The problem: User likes a post offline. The app is killed. When the app restarts and the feed refreshes, the server returns isLiked=false for that post (it hasn't synced yet). The heart un-fills — wrong and confusing.
The fix: The action_queue table is the solution. Before the RemoteMediator writes fresh posts to Room, it queries action_queue for all pending LIKE post IDs. When mapping API response to PostEntity, it overrides isLiked=true and increments likeCount for any post in that set — even though the server says otherwise. WorkManager's SyncActionsWorker will drain the queue once connectivity returns, after which future refreshes get the correct server state.
The problem: The user pulls to refresh at the exact moment Paging 3 auto-triggers a REFRESH (e.g., from a "New Posts" chip tap). Two REFRESH loads compete, potentially leaving the DB in an inconsistent state.
The fix: Paging 3 serialises concurrent loads — it cancels the in-flight REFRESH when a new one arrives. Because RemoteMediator uses a withTransaction { DELETE all; INSERT fresh } block, partial writes cannot leak. The final REFRESH always wins and leaves Room in a consistent state. The SwipeRefreshLayout spinner should be bound to loadStateFlow.collect { it.refresh is LoadState.Loading } — it will correctly show/hide based on whichever REFRESH is active.
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 images into disk cache without using memory. When RecyclerView binds those ViewHolders, Coil finds images in the disk cache (~30ms) rather than hitting the network. Gate this behind Wi-Fi only using NetworkCapabilities.hasTransport(TRANSPORT_WIFI).
Four things matter most: (1) setHasFixedSize(true) skips a full layout pass when items don't change the RecyclerView's own size. (2) DiffUtil (built into PagingDataAdapter) rebinds only changed ViewHolders — when the user likes a post, only that one item re-binds. (3) ConcatAdapter — merge StoriesAdapter + PagingDataAdapter into one flat RecyclerView. Nested horizontal RecyclerView for stories breaks the outer recycler's pool and causes scroll jank. (4) Cancel image loads on recycle — call imageView.dispose() (Coil) in onViewRecycled() to stop stale bitmap loads from arriving late.
class SyncActionsWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { override suspend fun doWork(): Result { val queue = db.actionQueueDao().getAll() queue.forEach { entry -> try { when (entry.action) { "LIKE" -> api.like(entry.postId) "UNLIKE" -> api.unlike(entry.postId) } db.actionQueueDao().delete(entry) // only delete on success } catch (e: Exception) { if (entry.retryCount >= 3) db.actionQueueDao().delete(entry) else db.actionQueueDao().incrementRetry(entry.id) } } return if (db.actionQueueDao().count() == 0) Result.success() else Result.retry() } }
Call adapter.refresh() from the SwipeRefreshLayout.setOnRefreshListener. Paging 3 re-triggers RemoteMediator.load(REFRESH). Observe adapter.loadStateFlow to show/hide the spinner: bind the SwipeRefreshLayout's refreshing state to loadStates.refresh is LoadState.Loading. Handle LoadState.Error to show a Snackbar with a Retry action. The spinner automatically hides on NotLoading.
- Knows Paging 3 exists and why
- Implements RecyclerView + ViewHolder correctly
- Loads images with Coil/Glide, shows placeholder
- Basic Room cache with manual pagination
- Handles config change via ViewModel
- Knows DiffUtil for efficient updates
- Optimistic like (but may miss rollback)
- Full RemoteMediator — Room as SSoT
- Cursor vs offset trade-offs, articulated clearly
- Optimistic like with full rollback + WorkManager queue
- Single ExoPlayer instance pattern
- ConcatAdapter for stories + feed
- Image prefetching on Wi-Fi
- action_queue + stale-like merge on REFRESH
- New-posts chip without scroll jump
- onTrimMemory → Coil/ExoPlayer release strategy
- Network transition: pause video preload on cellular
- Cache TTL via DataStore + stale-while-revalidate
- ExoPlayer crash recovery and AudioFocus lifecycle
- Competing REFRESH serialisation
- A/B testing feed ranking signals client-side
These are the most frequently probed follow-ups in real feed system design interviews at Meta, Snap, Google, Zomato, and Meesho.
Offset pagination (page=2&size=20) drifts when new posts are inserted between page fetches — the user sees duplicates or skipped posts. A cursor anchors to a specific post ID or timestamp, so even with concurrent inserts the next page starts from the exact item after the cursor. The trade-off: you can't jump to an arbitrary page, but feeds scroll linearly so this is fine.
PagingSource is the UI-facing source of truth — it reads from Room and emits pages. It knows nothing about the network. RemoteMediator is a boundary-event bridge: it reacts when PagingSource needs data that isn't in Room (APPEND boundary reached), hits the API, writes results into Room, and lets PagingSource naturally pick up the change via Room's reactive invalidation. This separation keeps PagingSource simple (pure Room queries) while RemoteMediator handles the complex network-to-Room sync logic.
The Paging engine tracks unconsumed items ahead. When that count drops below prefetchDistance (default equals pageSize), it triggers an APPEND load. Configure with PagingConfig(pageSize=20, prefetchDistance=5) — the next API call fires when the user is 5 items from the end, giving enough lead time before they scroll there.
An optimistic update applies the expected change immediately without waiting for server confirmation. For likes, this means flipping isLiked and incrementing likeCount in Room the instant 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 and show a snackbar. This is critical because users expect instant feedback on social interactions — any perceptible delay kills the UX.
The RemoteMediator pattern gives offline support essentially for free: Room is the SSoT. When offline, 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 a ConnectivityManager.NetworkCallback. Offline likes are queued via action_queue + WorkManager with a CONNECTED constraint — they sync automatically when connectivity returns.
Paging 3 automatically de-duplicates: it only runs one APPEND at a time, queueing subsequent requests. Additionally: use distinctUntilChanged() on the PagingData Flow to prevent identical pages causing re-emission; Room's PagingSource only emits on actual data changes; DiffUtil in PagingDataAdapter ensures only changed items re-bind. The net result: zero redundant API calls regardless of scroll speed.
Use a single ExoPlayer instance shared across all items — creating one per ViewHolder is prohibitively expensive. Attach a RecyclerView.OnScrollListener that on scroll settle finds the video ViewHolder with the largest visible area (≥50% visible). Detach the player from the old ViewHolder (playerView.player = null, player.stop()), then attach to the new one (playerView.player = player, set MediaItem, prepare(), play()). Release the player in Fragment.onDestroyView() to avoid AudioFocus and codec leaks.
PagingDataAdapter uses a DiffUtil.ItemCallback you provide. When a new page arrives, DiffUtil computes the minimum change set (inserts, removes, moves, changes) on a background thread. Only changed ViewHolders re-bind. When the user likes a post and likeCount changes, only that one item re-binds — the rest stay untouched. This means appending 20 new items animates a smooth insertion at the bottom without any visible jitter on the currently visible items.
Use a LoadStateAdapter attached via adapter.withLoadStateFooter(loadStateAdapter). It automatically shows a spinner while APPEND is loading, a Retry button on error, and hides itself when there's no active load. When RemoteMediator returns MediatorResult.Success(endOfPaginationReached=true), Paging 3 stops triggering APPEND and the footer disappears cleanly.
PagingData Flow lives in the ViewModel — it survives rotation. The Fragment re-subscribes to the same Flow; no API call is re-issued. RecyclerView's LinearLayoutManager saves and restores scroll position automatically via onSaveInstanceState(). Videos: the shared ExoPlayer re-attaches to the newly created PlayerView after the ViewHolder re-binds. Net result: the user sees the same scroll position with zero data fetch on rotation.
A nested horizontal RecyclerView for stories treats the stories row as a single ViewHolder that never properly recycles. It also causes nested scroll conflicts, poor memory usage, and breaks Paging 3's ability to manage a unified list. ConcatAdapter merges StoriesAdapter and PagingDataAdapter into one flat RecyclerView. Stories are item type 0, posts are item type 1. The RecycledViewPool is shared so ViewHolders recycle correctly across the entire merged list.
After RemoteMediator inserts the next page's posts into Room, fire headless Coil requests with memoryCachePolicy=DISABLED and diskCachePolicy=ENABLED. This downloads and decodes images into disk cache without occupying memory. When RecyclerView binds those ViewHolders, Coil hits disk cache (~30ms) instead of network. Gate behind Wi-Fi using NetworkCapabilities.hasTransport(TRANSPORT_WIFI) — never prefetch on cellular.
Call adapter.refresh() from the SwipeRefreshLayout listener. Paging 3 re-triggers RemoteMediator.load(REFRESH), which atomically clears old Room data and inserts fresh posts. Bind the SwipeRefreshLayout's isRefreshing to adapter.loadStateFlow.map { it.refresh is LoadState.Loading }.distinctUntilChanged(). Handle LoadState.Error to show a Snackbar with a Retry. The spinner auto-hides on NotLoading.
On FCM data push (new posts available): check layoutManager.findFirstCompletelyVisibleItemPosition() == 0. If at the top, auto-call adapter.refresh(). Otherwise, show a floating "New Posts ↑" chip above the RecyclerView (a separate View, not a list item). On chip tap: smoothScrollToPosition(0), then call adapter.refresh() inside the scroll listener once position 0 is reached. The chip being outside the list means it has zero impact on DiffUtil or Paging's item accounting.
Before RemoteMediator writes fresh posts to Room on REFRESH, it queries action_queue for all pending LIKE/UNLIKE post IDs. When mapping the API response to PostEntity, for any post in the pending set, it overrides the server's isLiked and likeCount values with the local optimistic state. This way, even if the server returns isLiked=false for a pending like, the UI shows the heart filled until WorkManager successfully syncs.
Override onTrimMemory(level) in your Application class. Priority order for release: (1) TRIM_MEMORY_UI_HIDDEN → trim Coil's memory cache to 50%. (2) TRIM_MEMORY_MODERATE → clear Coil memory cache fully; trim ExoPlayer's buffer (player.setPlaybackParameters to reduce buffering). (3) TRIM_MEMORY_COMPLETE → release ExoPlayer entirely (player.release()), clear any in-memory bitmap pools. Room and disk caches are never evicted here — they survive. On the next foreground event, recreate ExoPlayer lazily.
Use a sealed class FeedItem with subtypes: ImagePost, VideoPost, CarouselPost, AdPost. In PagingDataAdapter override getItemViewType() returning distinct integers per type. Create separate ViewHolder classes with different layouts in onCreateViewHolder(). Room stores all types in one posts table with a mediaType column. Ads come from the API with mediaType=AD — they have a different layout (no like/comment row, "Sponsored" label) and are inserted at server-defined positions in the response.
Coil automatically cancels requests bound via imageView.load(url) when the ImageView is detached (which happens on recycle). If using the ImageLoader manually, store the Disposable in the ViewHolder and call disposable.dispose() in Adapter.onViewRecycled(holder). For Glide, binding to the Fragment lifecycle via Glide.with(fragment) handles cancellation automatically when the Fragment is stopped or destroyed.
Use an in-memory Room database (Room.inMemoryDatabaseBuilder) and a fake API implementation that returns controlled responses. Call mediator.load(LoadType.REFRESH, pagingState) directly and assert: (1) MediatorResult.Success returned; (2) Room posts table contains expected entities; (3) remote_keys table has the correct cursor. Test error path: fake API throws IOException, assert MediatorResult.Error and Room is unchanged. Test the action_queue merge: pre-populate queue with a pending LIKE, verify the entity written to Room has isLiked=true even when API returns isLiked=false.
The server handles ranking — the client's job is to: (1) Send implicit engagement signals (view time per post, scroll speed, re-views) to a telemetry endpoint. Batch these in memory and flush every 30 seconds or on app background. (2) Receive a ranking experiment assignment via the API response header (X-Feed-Experiment: rankv2) or a remote config SDK call. (3) Store the assignment in DataStore and include it in every feed API request header so the server serves the appropriate ranked feed. The client never runs ranking itself — it provides signal inputs and receives the ranked output.