Android System Design: Design Reddit
Reddit is a popular senior Android system design question at companies like Reddit, Meta, Google, and TikTok. It sits in a productive zone: approachable enough to get started quickly, but with enough depth in pagination, offline strategy, vote state management, and media handling to fill a full 45-minute interview.
In a real interview, the interviewer may focus on a specific component — like the feed flow — and expect you to go deep on architecture, pagination, image loading, and offline handling for that area specifically. This guide covers the major building blocks with the depth and balance a senior interview expects.
Step 1: Clarify the Scope
Interviewer: Design the Reddit Android app.
Candidate: A few quick questions. Are we designing the full app, or specific flows? I'm thinking home feed with infinite scroll, post detail with nested comments, voting, and subreddit browsing. Should offline access be supported? What about media types — images and videos in the feed? And are push notifications and deep linking in scope?
Interviewer: Full core experience — feed, post detail, voting, and subreddit browsing. All media types. Offline is a hard requirement. Push notifications and deep linking are in scope.
Candidate: Great. The feed architecture and vote state are where the most interesting depth lives. Let me start with requirements and numbers, then walk through each component.
Requirements
Functional
- Home feed: infinite scroll of posts from subscribed subreddits
- Subreddit browsing: subscribe, view feed, rules, and about
- Post detail: full post with nested, collapsible comment tree
- Voting: upvote/downvote on posts and comments — immediate feedback, synced in background
- Media: text, images, GIFs, and video posts in the same feed
- Post creation: text and image posts
- Push notifications: replies, mentions, comment responses
- Deep linking: notification tap lands on the correct post or comment
Non-Functional
- Smooth infinite scroll — no blank cells or layout shifts during pagination
- Instant vote feedback — visual update in under 50ms
- Offline resilience — cached feed readable without network
- Memory safe — heterogeneous media across a long list must not OOM
- Battery aware — media autoplay respects network type
Back-of-the-Envelope Estimates
Interviewer: What numbers are you working with?
Candidate: On the client:
Feed page size: 25 posts per page
Post metadata: ~2 KB per post
Thumbnail: ~150–300 KB (CDN-served)
Comment depth: Reddit caps at 8 levels
Comments per post: Median ~50, popular posts ~1,000+
Local Room cache:
500 posts × 2 KB = ~1 MB metadata — trivially small
Images managed by Coil/Glide disk cache (~100 MB)
Pagination style:
Reddit API: cursor-based ("after" token) — not page numbersTwo things to note. First, pagination is cursor-based — not offset. This shapes the Paging 3 setup significantly. Second, a popular thread can have thousands of nested comments — the tree needs a specific flattening strategy. Memory is the real risk: 200 thumbnails in a long scroll session will OOM if image loading doesn't handle eviction.
Client Architecture
Interviewer: Walk me through the overall architecture.
Candidate: Clean Architecture with MVVM or MVI at the presentation layer, Paging 3 with
RemoteMediatorfor the feed, and Room as the persistent cache.
┌──────────────────────────────────────────────────┐
│ UI Layer │
│ HomeScreen SubredditScreen PostDetailScreen │
│ ↑ observes StateFlow / PagingData │
│ ViewModel (MVVM / MVI) │
└───────────────────┬──────────────────────────────┘
│
┌───────────────────▼──────────────────────────────┐
│ Domain Layer │
│ Use Cases: GetFeed, Vote, GetComments, │
│ Subscribe, GetPost, Search │
└───────────────────┬──────────────────────────────┘
│
┌───────────────────▼──────────────────────────────┐
│ Data Layer │
│ │
│ FeedRepository │
│ ├── RemoteDataSource (Retrofit) │
│ └── LocalDataSource (Room) │
│ │
│ VoteRepository │
│ ├── Room (instant optimistic write) │
│ └── WorkManager (VoteSyncWorker) │
└──────────────────────────────────────────────────┘The single most important principle: Room is the single source of truth.
Every screen observes Room via Flow. The network populates Room; Room drives the UI. This is what makes offline seamless — the UI doesn't know or care whether data came from a network response or the local cache from yesterday.
Why MVI over plain MVVM?
Reddit's feed has complex, multi-dimensional state: loading, empty, error, refreshing, and per-item vote state that changes independently of the list. MVI's single UiState object keeps all of this in one observable, preventing the inconsistencies that creep in with multiple independent StateFlow fields.
Block 1: Infinite Feed with Paging 3 and RemoteMediator
This is the centrepiece — and what interviewers probe most on Reddit-style questions.
The two Paging 3 approaches:
A plain PagingSource reads from a single source — network or local database, not both. RemoteMediator coordinates both: when the user scrolls to the end of locally cached data, it fetches the next page from the network, writes it to Room, and Room's PagingSource emits the updated data automatically. Room stays the source of truth.
Interviewer: Walk me through the feed loading flow.
Candidate:
FIRST OPEN
──────────────────────────────────────────────────
Pager reads from Room (FeedPagingSource)
│
├─ Cache is fresh (< 1h old)?
│ → SKIP_INITIAL_REFRESH
│ Shows cached data instantly, no network wait
│
└─ Cache is stale?
→ LAUNCH_INITIAL_REFRESH
Fetches page 1 from network in background
Writes to Room → PagingSource invalidates → UI updates
SCROLL TO NEAR END
──────────────────────────────────────────────────
Paging 3 triggers RemoteMediator.load(APPEND)
│
▼
Read "after" cursor from RemoteKeys table
(stored alongside posts on previous load)
│
▼
GET /hot?after={cursor}&limit=25
│
▼
Database transaction:
CLEAR old data (REFRESH only) / APPEND new posts
INSERT posts into Room
INSERT RemoteKeys (next cursor per post)
│
▼
PagingSource invalidates → new rows appear at bottomThe RemoteKeys table is a critical supporting table that stores the after cursor token alongside each post. Without it, Paging 3 has no reliable anchor for which cursor to use when the user is deep in the list or after a process kill.
Cursor-based vs offset pagination:
Interviewer: Why cursor-based and not page numbers?
Candidate: Offset pagination (
page=2) breaks on a live feed. If 10 new posts appear at the top between page 1 and page 2, page 2 shifts — items repeat or get skipped. A cursor anchors to the last seen item ID, soafter=t3_xyzalways returns the next 25 posts after that specific post, regardless of how the top of the feed has changed. This is more resilient and is what Reddit's API actually uses.
InitializeAction.SKIP_INITIAL_REFRESH is a clean optimisation worth naming explicitly. When the RemoteMediator detects fresh cached data on initialize(), it returns SKIP_INITIAL_REFRESH — skipping the network call on app open. Users on frequently-opened apps see their feed instantly from Room, with a background refresh happening silently. This is a meaningful improvement to perceived app startup time.
Handling REFRESH vs APPEND:
On REFRESH (pull-to-refresh or stale cache), clear the existing posts and remote keys before writing fresh data. On APPEND, only insert the new page — don't touch existing posts. Mixing these up causes the list to reset to the top mid-scroll.
All Room writes during a mediator load are wrapped in a withTransaction block. If any write fails, none of them persist — preventing a partial state where some posts exist but their cursor keys don't.
Block 2: Room Schema
Interviewer: Walk me through the key entities.
Candidate:
posts
─────────────────────────────────────────────────
id TEXT (PK) "t3_abc123"
subreddit TEXT
title TEXT
authorName TEXT
score INT denormalised — updated on vote
commentCount INT
postType ENUM TEXT / IMAGE / VIDEO / LINK
thumbnailUrl TEXT?
imageUrl TEXT?
videoUrl TEXT?
selfText TEXT?
userVoteDirection INT -1 / 0 / +1 — local vote state
cachedAt LONG drives TTL invalidation strategy
comments
─────────────────────────────────────────────────
id TEXT (PK)
postId TEXT (FK)
parentId TEXT post ID or parent comment ID
body TEXT
score INT
depth INT 0=top-level, 1=reply, 2=reply-reply…
userVoteDirection INT
isCollapsed BOOLEAN local-only — never synced to server
isHidden BOOLEAN local-only — child of a collapsed comment
cachedAt LONG
subreddits
─────────────────────────────────────────────────
name TEXT (PK) "r/androiddev"
displayName TEXT
subscriberCount INT
isSubscribed BOOLEAN optimistic local state
cachedAt LONGKey design decisions:
userVoteDirection on both posts and comments powers the optimistic vote UI — the client owns this state and syncs asynchronously.
isCollapsed and isHidden on comments are local-only fields — they never sync to the server. isCollapsed tracks whether a user folded a branch; isHidden marks all children of a collapsed parent so the DAO query can filter them out. Persisting these in Room means the collapsed state survives screen navigation — Back → Forward → still collapsed.
cachedAt on every entity enables TTL-based cache invalidation without a separate tracking table.
Block 3: Optimistic Voting — The Most Common Follow-Up
Interviewer: The user taps upvote. Walk me through what happens — including the failure path.
Candidate: Three phases: immediate local update, background sync, and rollback on failure.
TAP UPVOTE
───────────────────────────────────────────────────────────
ViewModel receives VoteEvent(postId, newDirection, prevDirection)
│
├─► Phase 1: Immediate Room write (< 50ms)
│ UPDATE posts
│ SET userVoteDirection = newDirection,
│ score = score + delta
│ WHERE id = postId
│
│ Room emits → UI reacts → button colour changes, score updates
│
└─► Phase 2: Enqueue VoteSyncWorker (WorkManager)
UniqueWork name: "vote_{postId}"
ExistingWorkPolicy: REPLACE
Constraint: NetworkType.CONNECTED
Backoff: EXPONENTIAL (10s, 20s, 40s…)
VoteSyncWorker runs when network is available
───────────────────────────────────────────────────────────
│
├─ SUCCESS
│ POST /vote { id, direction } → 200 OK
│ Room state already correct — nothing to update
│
└─ PERMANENT FAILURE (post deleted, auth error, etc.)
Phase 3: Rollback
UPDATE posts
SET userVoteDirection = prevDirection,
score = score - delta
WHERE id = postId
Room emits → vote indicator reverts
Snackbar: "Vote couldn't be registered"Interviewer: Why
ExistingWorkPolicy.REPLACE?
Candidate: If the user taps upvote, then immediately taps again to remove it, two WorkManager tasks could be queued for the same post.
REPLACEcancels the first and keeps only the latest. Without this, both tasks fire — the server sees upvote then removal in sequence, but on a slow network they could arrive in the wrong order.REPLACEensures exactly one vote sync per post per moment.
Interviewer: What if the user votes while offline and then doesn't open the app for two days?
Candidate: WorkManager persists its queue to disk. The
VoteSyncWorkeris still queued with aCONNECTEDconstraint. When connectivity returns — even days later — it fires. The local Room state has shown the voted state all along, and it gets confirmed server-side when the worker runs. No user-visible disruption.
If you want to practice explaining these three phases clearly — and handling the follow-ups that come after — Mockingly.ai has Android social media simulations where optimistic UI with rollback is a standard deep-dive.
Block 4: Image Loading and Memory Management
Interviewer: How do you handle images in the feed without running into memory issues?
Candidate: Use Coil (Kotlin-first, coroutines-native) or Glide — both manage a two-layer cache: in-memory LRU and disk LRU. The two things that matter most for a feed:
1. Decode at display size, not source size.
If a thumbnail slot is 300×200dp, request the image at those exact dimensions. Coil's size(ViewSizeResolver(imageView)) does this automatically. Without it, a 4K JPEG decodes to ~32 MB in RAM. At display size, the same image is under 1 MB.
2. Cancel in-flight requests on ViewHolder recycle.
Both Coil and Glide cancel requests tied to an ImageView automatically when the view is recycled — no manual cleanup needed. The key is to always bind the load to the view's lifecycle, not to a coroutine scope that outlives the ViewHolder.
Image request lifecycle:
onBind() → Coil.load(url) → checks memory cache → checks disk cache → network
onViewRecycled() → Coil cancels in-flight request automaticallyFor videos: use a single shared ExoPlayer instance managed by a VideoPlaybackManager singleton — not one player per feed item. Multiple simultaneous ExoPlayer instances exhaust hardware codec slots. The manager tracks which video item is most visible (via scroll position) and routes the single player to that ViewHolder. When a new item becomes primary, the player is detached from the old one and attached to the new.
Multiple view types in the adapter: the adapter uses getItemViewType() to route each post to the correct ViewHolder — TextPostViewHolder, ImagePostViewHolder, VideoPostViewHolder, LinkPostViewHolder. This keeps onBindViewHolder clean — each holder only knows its own type, with no branching.
Block 5: Nested Comments
Interviewer: How do you render a deep comment tree without performance issues?
Candidate: Flatten the tree to a list using the
depthfield, then useisCollapsed/isHiddenflags for collapse/expand — both stored locally in Room.
Flat list rendering:
Comment tree (in Room):
A (depth 0)
B (depth 1)
C (depth 2)
D (depth 1)
DAO query: SELECT * FROM comments WHERE postId = X AND isHidden = 0
Result (flat list):
A → rendered with no indent
B → rendered with one-level indent
C → rendered with two-level indent
D → rendered with one-level indentCollapse flow:
User taps comment B header
│
▼
ViewModel: UPDATE comments SET isCollapsed = true WHERE id = B
UPDATE comments SET isHidden = true WHERE parentId = B (recursive)
│
▼
DAO query re-runs: isHidden = 0 filters C out of results
│
▼
DiffUtil computes minimal diff — C disappears from list in-place
Comment B now shows "▶ 1 reply collapsed"Two-pass loading for large threads: on post open, load only top-level comments and 2 levels deep — enough for the user to start reading immediately. Deeper sub-trees are loaded lazily when the user taps a "Load N more replies" placeholder. This is how Reddit's API works natively, returning moreChildren tokens for truncated branches.
Block 6: Offline Strategy
Interviewer: How does the app behave with no internet?
Candidate: Room-first, always. The key decisions:
Cache freshness via InitializeAction:
override suspend fun initialize(): InitializeAction {
val age = System.currentTimeMillis() - (latestCachedPost?.cachedAt ?: 0)
return if (age < TimeUnit.HOURS.toMillis(1))
InitializeAction.SKIP_INITIAL_REFRESH // serve from cache, skip network
else
InitializeAction.LAUNCH_INITIAL_REFRESH // fetch in background
}When cache is fresh, no network call happens on app open — the feed renders instantly from Room. When cache is stale, RemoteMediator fetches in the background while the stale data is already showing.
Offline UX:
App opens offline
│
▼
Room has cached posts → renders immediately
RemoteMediator.load() → IOException → MediatorResult.Error
│
▼
Paging 3 shows cached data + offline footer state
ConnectivityManager detects offline → "Offline" banner (non-blocking)
Network returns
│
▼
NetworkCallback.onAvailable() fires
→ Banner dismisses
→ RemoteMediator retries REFRESH automatically
→ Fresh posts write to Room → UI updatesPending writes while offline: votes and other write actions are queued via WorkManager with a CONNECTED constraint. They execute automatically when network returns — even if the app was killed in between. Room shows the optimistic state throughout. No user action required to replay pending writes.
Block 7: Push Notifications and Deep Linking
Interviewer: The user gets a notification — "Alice replied to your comment." They tap it. What happens?
Candidate: Two systems: FCM for delivery, Jetpack Navigation for landing.
FCM data message (not notification message):
Server sends FCM data payload:
{
type: "comment_reply",
post_id: "t3_abc123",
comment_id: "t1_xyz789",
author: "Alice",
preview: "That's a great point..."
}
FirebaseMessagingService.onMessageReceived()
→ Parse payload
→ Optionally write to Room (pre-load the post/comment data)
→ Post MessagingStyle notification with a deep link PendingIntentData messages (not notification messages) give full control — onMessageReceived() fires in all app states, the app decides what to display, and the notification can be suppressed if the user is already on the target screen.
Deep linking via NavDeepLinkBuilder:
val pendingIntent = NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.postDetailFragment)
.setArguments(bundleOf("postId" to postId, "highlightCommentId" to commentId))
.createPendingIntent()NavDeepLinkBuilder uses TaskStackBuilder under the hood to synthesise the correct back stack. When the user presses Back from the deep-linked screen, they land on the home feed — not exit the app. Without this, Back would exit the app, which is a common bug in notification implementations.
In PostDetailFragment: if highlightCommentId is present in the args, scroll the comment list to that comment and briefly highlight it. Load from Room first (likely already cached), fall back to network if not.
Block 8: Subreddit Subscription
Interviewer: The user subscribes to a subreddit. How does that flow work?
Candidate: Same optimistic pattern as voting.
User taps Subscribe
│
▼
Room: UPDATE subreddits SET isSubscribed = true WHERE name = X
UI updates immediately — button state changes
SubscribeWorker enqueued (WorkManager, CONNECTED constraint)
│
├─ SUCCESS → Room state correct — nothing to do
└─ FAILURE → Rollback: SET isSubscribed = false
Snackbar: "Couldn't subscribe. Try again."The home feed DAO query joins posts against subreddits WHERE isSubscribed = true. When subscription state changes in Room, the feed updates reactively — no manual refresh needed.
Common Interview Follow-ups
"How do you prevent the feed jumping when new posts load?"
On pull-to-refresh, adapter.refresh() triggers a REFRESH load. Room clears and replaces from the top. The list resets to position 0 with new content — intentional and expected on explicit refresh. For a "N new posts" banner (Twitter-style), buffer new posts and only insert them when the user taps the banner, not automatically mid-session.
"How do you handle a post that's deleted by the server while the user is offline, but they voted on it locally?"
VoteSyncWorker gets a 404. The worker catches this as non-retryable, calls Result.failure(), and rolls back the local vote in Room. The post will also be missing from the next delta sync. The Room DAO query for the feed naturally excludes it after the next refresh. No crash, no infinite retry, graceful degradation.
"How do you handle 1,000 comments without a long initial wait?"
Two-pass loading. Pass 1: fetch top-level comments and 2 levels deep on post open — renders immediately. Pass 2: deeper sub-trees load lazily on "Load N more" tap. Reddit's own API returns moreChildren tokens for truncated branches. The client only fetches what the user actually expands.
"How does the search work?"
Subreddit search uses debounced network calls (300ms after last keystroke) with results cached in Room for 5 minutes. Post search uses a network-only PagingSource — no RemoteMediator, no Room cache. Search results are too query-specific to cache meaningfully. The simpler PagingSource is the right trade-off here.
Quick Interview Checklist
- ✅ Clarified scope — feed, subreddit, post detail, votes, media, notifications, deep linking
- ✅ Clean Architecture + MVVM/MVI — single
UiStatefor complex multi-dimensional state - ✅ Room as single source of truth — network fills Room, UI observes Room exclusively
- ✅ Paging 3 with
RemoteMediator— cursor-based ("after" token), not offset pagination - ✅
RemoteKeystable — stores cursor alongside posts for stable pagination across sessions - ✅
SKIP_INITIAL_REFRESHfor fresh cache — no network wait on fast opens - ✅
withTransactionwrapping Room writes — atomicity on page load - ✅ REFRESH clears and replaces; APPEND only inserts new rows
- ✅ Room schema:
userVoteDirection,isCollapsed/isHidden(local-only),cachedAt - ✅ Optimistic voting: Room write → WorkManager sync → rollback on permanent failure
- ✅
ExistingWorkPolicy.REPLACE— latest vote wins, stale syncs cancelled - ✅ WorkManager queued with
CONNECTEDconstraint — replays on network return, survives process kill - ✅ Image loading at display size — prevents OOM; in-flight cancel on ViewHolder recycle
- ✅ Single shared ExoPlayer via
VideoPlaybackManager— no per-item player - ✅ Multiple ViewHolder types — no branching in
onBindViewHolder - ✅ Comment tree flattened via
depth—isCollapsed/isHiddendrive in-place collapse - ✅ Two-pass comment loading — top levels upfront, deep branches on tap
- ✅ Offline:
InitializeActionfor fast opens, WorkManager queues pending writes - ✅ FCM data messages —
onMessageReceived()in all states, suppress if already on target screen - ✅
NavDeepLinkBuilder— synthesises correct back stack from notification tap - ✅ Subreddit subscription: optimistic Room write → WorkManager sync → rollback
Conclusion
Designing Reddit for Android is a state management problem with a content feed on top. Every user action — vote, collapse, subscribe — is a local Room mutation first and a network sync second. The offline resilience and rollback paths for each action are as important as the happy path.
In real interviews, sketching the high-level architecture, explaining pagination and optimistic UI updates, describing the offline strategy for queuing votes and syncing them later, and discussing consistency trade-offs are the core expectations.
The design pillars:
- Room as single source of truth — the network fills Room; the UI observes Room only
- Paging 3 with RemoteMediator — cursor-based, transactional,
InitializeActionfor fast opens - Optimistic mutations with WorkManager rollback — votes, subscriptions, posts — all follow the same three-phase pattern
ExistingWorkPolicy.REPLACE— ensures latest user intent wins; no stale syncs- Decode images at display size — the single most impactful memory safety measure
- Flat list for comments —
depthfor nesting, Room flags for collapse state, two-pass loading for large threads NavDeepLinkBuilderfor notifications — correct back stack, no "exit app on Back" bug
Frequently Asked Questions
What is optimistic UI and how does it work for voting in Android?
Optimistic UI is a pattern where the interface updates immediately to reflect a user action — before the server has confirmed it — then rolls back if the server rejects it.
How it works for an upvote in three phases:
- Immediate Room write —
UPDATE posts SET userVoteDirection = 1, score = score + 1 WHERE id = postId. Room emits the change, the vote button changes colour and the score increments in under 50ms. No network call has happened yet - Background WorkManager sync —
VoteSyncWorkeris enqueued withExistingWorkPolicy.REPLACEand aCONNECTEDconstraint. It runs when network is available and POSTs the vote to the server - Rollback on permanent failure — if the server returns a non-retryable error (post deleted, auth expired), the worker reverts the Room update to the previous
userVoteDirectionandscore. Room emits again, the UI reverts, and a Snackbar notifies the user
The result: the user gets instant feedback, the server eventually receives the vote, and failures are handled gracefully without silent data corruption.
What is the difference between PagingSource and RemoteMediator in Paging 3?
PagingSource loads data from a single source. RemoteMediator coordinates between a remote source (network) and a local cache (Room), using Room as the single source of truth.
| PagingSource | RemoteMediator | |
|---|---|---|
| Data source | One source only | Network + Room together |
| Offline support | No — fails if source unavailable | Yes — serves cached Room data instantly |
| Complexity | Simple | More setup required |
| Best for | Network-only lists (e.g. search results) | Feeds that need caching and offline access |
How RemoteMediator works for a Reddit feed:
- The
Pageralways reads from Room'sPagingSource— the UI observes only Room - When the user scrolls near the end, Paging 3 calls
RemoteMediator.load(APPEND) - The
RemoteMediatorfetches the next page from the network using a cursor token - New posts are written to Room in a
withTransactionblock - Room's
PagingSourcedetects the new rows and emits the updated list automatically
Use RemoteMediator for any feed that needs to work offline and show cached content instantly on app open. Use a plain PagingSource for search results or any list that is always fresh from the network.
Why use cursor-based pagination instead of offset pagination for the Reddit feed?
Cursor-based pagination anchors to a specific item ID. Offset pagination uses a page number or row offset. For a live feed, offset breaks — cursor does not.
The problem with offset on a live feed:
- The feed at
/posts?page=2returns rows 26–50 when page 1 loaded - While the user reads page 1, 5 new posts are added at the top
- When page 2 loads, rows 26–50 now include 5 posts the user already saw on page 1
- The user sees duplicates — or worse, misses posts entirely if items were removed
How cursor-based fixes this:
- Page 1 returns posts 1–25 and an
aftercursor ="t3_xyz"(the ID of post 25) - Page 2 fetches
/posts?after=t3_xyz— returns the 25 posts after that specific item - New posts added at the top do not affect the cursor anchor
- No duplicates, no skipped posts, regardless of feed activity
Reddit's own API uses the after token for exactly this reason. The RemoteKeys Room table stores this cursor token alongside each post so Paging 3 can resume pagination correctly after process kills.
How do you implement optimistic voting with rollback in Android?
Optimistic voting with rollback means updating Room immediately on tap, syncing to the server in the background, and reverting the Room state if the sync permanently fails.
Full implementation flow:
- User taps upvote → ViewModel receives
VoteEvent(postId, newDirection=1, prevDirection=0) - ViewModel writes to Room:
userVoteDirection = 1,score = score + 1 - UI reacts to Room
Flowemission — button colour changes, score updates (under 50ms) - WorkManager enqueues
VoteSyncWorkerwith:ExistingWorkPolicy.REPLACE— cancels any pending vote sync for the same postNetworkType.CONNECTEDconstraint — waits for connectivity- Exponential backoff — retries on transient failures (network error, 429)
- On server
SUCCESS— Room already reflects the correct state, nothing to update - On
IOExceptionor429—Result.retry(), backoff applies - On permanent failure (404 post deleted, 401 auth) — rollback Room to
prevDirectionand revertscore
Why ExistingWorkPolicy.REPLACE is critical:
If the user taps upvote then immediately taps again to remove the vote, two workers would be queued for the same post. REPLACE cancels the first and keeps only the latest — ensuring the final server state matches the user's last intent.
How do you flatten and render a nested comment tree in a RecyclerView?
Nested comments are flattened to a list using a depth field on each comment, with isCollapsed and isHidden flags driving collapse and expand — all stored in Room.
The data model:
- Each
CommentEntityhas adepthINT (0 = top-level, 1 = reply, 2 = reply-to-reply…) isCollapsed— local-only flag, never synced to server. True when the user taps the comment header to fold the branchisHidden— local-only flag. Set to true on all children of a collapsed comment- The Room DAO query filters:
SELECT * FROM comments WHERE postId = X AND isHidden = 0
Collapse flow:
- User taps comment header →
isCollapsed = trueon that comment,isHidden = trueon all children - Room emits the filtered list — hidden comments disappear from the result
ListAdapterDiffUtil computes the minimal diff — only the collapsed rows are removed- The collapsed comment shows a "▶ N replies" indicator
- Tap again →
isCollapsed = false,isHidden = falseon direct children → they reappear in-place
Why store these in Room (not just ViewModel memory)?
Collapse state survives screen navigation. If the user collapses a branch, taps Back, and returns — the branch is still collapsed. In-memory state would reset to fully expanded on every screen enter.
How does the offline feed work with Room and RemoteMediator?
Offline feed support means the user sees the last cached posts from Room immediately — with no network required and no blank screen.
How it works:
- On app open, Paging 3 reads from Room's
PagingSourceinstantly — cached posts render before any network call RemoteMediator.initialize()checks thecachedAttimestamp of the most recent post:- If cache is fresh (< 1 hour old) → returns
SKIP_INITIAL_REFRESH. No network call, cached data shows instantly - If cache is stale → returns
LAUNCH_INITIAL_REFRESH. Fetches fresh data in background while stale cache is already showing
- If cache is fresh (< 1 hour old) → returns
- If the device is offline,
RemoteMediator.load()throwsIOException→ returnsMediatorResult.Error. Paging 3 surfaces a retry footer, but the cached data already on screen remains visible ConnectivityManager.NetworkCallback.onAvailable()fires when connectivity returns →RemoteMediatorautomatically retriesREFRESH→ fresh posts write to Room → UI updates reactively
Pending writes while offline:
Votes and subscriptions made offline are queued by WorkManager with a CONNECTED constraint. When network returns, WorkManager fires the queued jobs automatically — even if the app was killed between going offline and reconnecting.
When should you use MVI instead of MVVM for an Android feed?
MVI (Model-View-Intent) uses a single immutable UiState object observed by the UI. MVVM typically exposes multiple independent StateFlow or LiveData fields.
MVI is the better choice when UI state has multiple dimensions that change independently:
| Situation | MVVM risk | MVI solution |
|---|---|---|
| Feed loading + per-item vote state | Two StateFlows can diverge — UI shows stale vote on refresh | Single UiState updated atomically |
| Error state + partial data | Error clears the list, losing scroll position | UiState(error=..., posts=cachedList) preserves both |
| Offline banner + loading indicator | Two separate flags can both be true simultaneously | State machine enforces valid state combinations |
| Feed refresh + append in flight | Refresh cancels append but flags don't clear | Single isRefreshing vs isAppending field in UiState |
For a Reddit-style feed specifically:
The feed has loading, error, empty, refreshing, offline, and per-item vote states — all potentially active at different times. A single UiState data class prevents the state inconsistencies that come from observing six separate StateFlow fields. For simpler screens with one or two states, MVVM is fine.
How do you implement deep linking from a push notification with the correct back stack?
NavDeepLinkBuilder constructs a PendingIntent that synthesises the correct back stack so the user lands on the right screen and Back navigates to the home feed — not exits the app.
How to implement it correctly:
- Server sends an FCM data message (not a notification message) with
post_idandcomment_id FirebaseMessagingService.onMessageReceived()parses the payload- Build the
PendingIntent:
val pendingIntent = NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.postDetailFragment)
.setArguments(bundleOf(
"postId" to postId,
"highlightCommentId" to commentId
))
.createPendingIntent()- Attach to a
NotificationCompat.Builderas thecontentIntent - When tapped, the user lands on
PostDetailFragmentwith the post pre-loaded - The synthesised back stack means Back → HomeFragment, not app exit
Why FCM data messages, not notification messages?
Notification messages are handled by the OS — onMessageReceived() is never called when the app is in the background. The app cannot suppress the notification if the user is already on that screen, cannot pre-load Room data, and cannot customise the PendingIntent. Data messages give the app full control in all states.
Which companies ask the Android Reddit system design question?
Reddit, Meta, Google, TikTok, Pinterest, and Twitter (X) ask variants of this question for senior Android engineer roles.
Why it is a popular interview question:
- State management complexity — voting, collapsing, subscribing all require optimistic updates with rollback
- Feed breadth — covers Paging 3, Room, image loading, offline strategy, and media handling in a single question
- Scales to seniority — a junior candidate talks about showing a list; a senior candidate explains cursor pagination,
ExistingWorkPolicy.REPLACE,isHiddencomment flags, andInitializeAction.SKIP_INITIAL_REFRESH - Real product — every company listed either runs a feed product or a social platform with voting, making the answer directly applicable
What interviewers specifically listen for:
- Explaining why cursor-based, not offset pagination — and the duplicate-post failure mode
- The three-phase optimistic vote pattern — Room write, WorkManager sync, rollback
ExistingWorkPolicy.REPLACE— and the specific race condition it preventsisCollapsed/isHiddenstored in Room — not in ViewModel memory — and whySKIP_INITIAL_REFRESHfor fast app opens — signals deep Paging 3 knowledge
Reddit interviews often go off-script fast — vote rollback under no signal, comment collapse persistence across navigation, what happens when a post is deleted mid-sync. Being fluent in these edge cases under real interview pressure is a different skill from understanding them on paper. Mockingly.ai has Android-focused system design simulations for engineers preparing for senior roles at Reddit, Meta, Google, and TikTok.