ReadThat 4: Post Detail & Comments
The deeply nested comment problem end to end: data structures, server-side best-first tree building, two-phase loading, the anti-flicker merge, iterative flattening, backend tradeoffs, the metrics that matter, and how to A/B test the alternatives.
Part 4 of the ReadThat case study.
📲 Try it live: Download the ReadThat APK (8.3 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.
TL;DR: why comments are hard
Comment thread has these requirements:
Functional
- arbitrarily deep, arbitrarily wide tree
- comments can contain media & rich text
- sorting by Best, New, Top, Q/A, Controversial, Old
Non-Functional
- load instantly on mobile
Deep and content heavy comment threads and can hold more nodes than a phone should prefetch. This makes comments an interesting data structure, client/server boundary decision, UI implementation and cacheing problem. Every decision below is about choosing which part of the tree to materialize, where, and when, and doing it without ever visibly reordering what the user is reading. Reddit engineers outlined the problem and improvements made to make ‘instant comments’ in this r/RedditEng post: https://www.reddit.com/r/RedditEng/comments/1cwqqtp/instant_comment_loading_on_android_ios/ . ReadThat implementation will be applying some of those concepts. The backend and some of details in ReadThat are for sure simplified.
Left: feed to post detail, with the prefetched first phase rendering instantly. Right: a 20-level-deep fixture thread: depth rails, collapse badges, and the continue-thread handoff at the depth cap.
The Challenge - show comments instantly
- Depth is unbounded in the data but not in the UX: indentation past ~10 levels is unreadable on a phone, and recursive algorithms on real threads are a
StackOverflowError. - Size is unbounded: a viral post has 50k comments. You need a principled answer to “which N do we send first?”
- What’s worth rendering is a ranking problem: the best reply to the third comment may matter more than the ninth root comment.
- Loading is incremental: every truncation point needs a cursor, and expanding one must splice into the middle of an existing tree without disturbing it.
- State overlays structure: votes, collapses, and in-flight replies are per-viewer state on top of shared structure.
- Sever request must not recompute on each client request: this would get expensive and add latency. What is the correct caching strategy?
Data structures and schema
Wire: a recursive tree of two node types, Comment (with children) and LoadMore (a cursor standing in for absent children, carrying at most 100 child IDs). CommentWire.kt
Storage: account-scoped Room cached_documents rows keyed by post/root/focus scope. The current shared repository persists the serialized tree envelope as a bounded document (maximum 512 documents per account, with time-based pruning), while the retained detail controller is the hot in-process tier. The tree algorithms remain iterative, so a deep thread cannot consume the call stack even though the durable representation is a document.
Render: a flat List<CommentRow> where each row carries renderDepth. The flattener, merger, and wire model now live in KMP commonMain (shared/), so Android and iOS run byte-identical tree logic:
sealed interface CommentRow { val key: String /** Depth on THIS SCREEN — always relative to this screen's root. Distinct * from structural depth, which is the server's business and never * crosses the wire. */ val renderDepth: Int
data class Comment(..., val isCollapsed: Boolean, val collapsedDescendants: Int) : CommentRow data class LoadMore(..., val parentId: String?) : CommentRow /** Depth-cap affordance: NAVIGATES to a re-rooted screen (permalink * behavior). Different intent, different row type, different metric. */ data class ContinueThread(..., val parentId: String?) : CommentRow}Server vs client processing
The central tradeoff: who builds the tree?
| Approach | Pro | Con |
|---|---|---|
| Client builds from a flat page of comments | simple server, cacheable pages | client re-implements ranking; first render waits on client CPU; can’t rank globally without all data |
| Server builds a best-first tree at fixed sizes (Reddit’s, and ReadThat’s) | globally score-ranked selection; pre-computable and cacheable; client just renders | server CPU per build; cache invalidation on new comments; fixed sizes only |
| Server streams, client assembles | freshest | complexity of both, cache-hostile |
Reddit’s published algorithm, implemented literally in the Worker: push roots into a max-heap by score, repeatedly pop the best comment anywhere in the tree, attach it, push its children as candidates; whatever remains groups by parent into load_more cursors:
const heap = new MaxHeap();for (const root of byParent.get(rootCommentId) ?? []) heap.push({ row: root, depth: 0 });const selected = new Map<...>();
while (heap.size > 0 && selected.size < maxCount) { const candidate = heap.pop(); ... if (candidate.depth + 1 <= maxDepth) { for (const child of children) heap.push({ row: child, depth: candidate.depth + 1 }); }}Because the heap is score-ordered globally, a count=200 tree expands children under comments that were childless leaves at count=8. There’s a test asserting this happens; it’s the premise of the flicker bug below. Depth caps at 10 (Reddit’s number) and every cursor carries ≤ 100 child IDs (comments.ts#L14).
Why fixed sizes (8, then 200)? Reddit pre-computes and caches trees at those exact parameters; clients that request the same sizes hit the cache. Ask for an off-menu size and you force a dynamic build. A JVM test pins the client to exactly (8, 10) then (200, 10).
Client implementation
Two-phase load. Request the 8-tree, render immediately; request the 200-tree, merge in behind the reader. Prefetch makes phase 1 free: shared feed UI dwell-gates SharedFeedController.prefetchComments, which persists the small tree through OfflineFirstRepository.prefetchComments; tap-through can therefore paint from Room before touching the network. A simultaneous prefetch and detail open also await the same in-flight phase-one request. Reddit’s published cost for the same feature: ~40k extra requests/second. Prefetching isn’t free, which is why dwell gates it. (controller, repository)
The anti-flicker merge. Reddit’s published problem: the 200-tree suddenly auto-expands children and “leads to a jarring UX.” The client-side contract:
/** * Anything already on screen keeps its position and its expansion state. * The larger tree may only ADD. It may never auto-expand something the * small tree showed collapsed, and never reorder what the user is reading. */fun merge(existing: CommentTree?, incoming: CommentTree, ...): MergeResultNodes that were childless on screen but gained children get auto-collapsed: and those IDs are kept in a separate set from user collapses: never persisted, never announced to TalkBack, because merger artifacts are not user intent. CommentTreeMerger.kt#L26-L86
Iterative flattening. Tree → flat rows with an explicit stack, honouring collapse (hides the whole subtree, reports collapsedDescendants for the “+12” badge), load-more cursors at the depth of the children they replace, and the depth-10 switch from expand-in-place to “continue this thread” navigation:
val stack = ArrayDeque<Pair<CommentNode, Int>>()for (node in tree.roots.asReversed()) stack.addLast(node to 0)while (stack.isNotEmpty()) { val (node, depth) = stack.removeLast() ...}CommentFlattener.kt#L86-L177: there’s a test flattening a 5,000-deep thread; recursion would blow the stack. Tree mutations (splice, vote, reply) are also iterative and path-copy, so untouched subtrees keep referential identity and Compose skips them.
Structure-only render derivation. Loading/error flags live in DetailState, but flattening first projects that state down to only (tree, collapsedIds), applies distinctUntilChanged, and moves the actual flatten to Dispatchers.Default. Flag churn therefore never re-flattens a large tree; Compose combines the stable render list with the cheap screen flags at the presentation edge (SharedDetailPresentation.kt). Detailed in part 10.
Why not Paging 3 here? A PagingSource models a flat ordered sequence; a comment continuation splices under any parent and must preserve collapse state and depth. Flattening server data first would throw away exactly the structure the feature mutates. The tree-aware equivalent: viewport visibility is a UDF intent, the ViewModel prefetches one cursor when it’s within six rows, one automatic branch in flight, errors never auto-retry.
Metrics: what to optimize for
Anchor metric: Comments TTI: tap in feed → first frame containing a real comment. Reddit’s baseline was ~2.3s iOS / ~2.6s Android; after the two-phase split, prefetch, and payload trims they reported p90 −60.9% / −59.4%, a −30% detail-load failure rate, and +4% comments viewed (Instant Comment Loading).
The hierarchy, from top-line to diagnostic:
| Tier | Metric | Why |
|---|---|---|
| Top line | DAU, retention | what everything rolls up to |
| Engagement | comments viewed per post, total comments viewed, comments posted per session, vote/karma interactions | the direct product outcomes of this screen |
| Experience | Comments TTI (p50/p90/p99, split prefetched vs demand), detail-load failure rate | the levers; Reddit’s data ties failure rate directly to fewer posts viewed |
| Diagnostic | initial-8 latency, full-200 latency, continuation latency, flatten duration, frame health | where to look when a lever moves |
ReadThat’s budgets: prefetched Comments TTI p90 ≤ 1,000 ms; initial-8 fetch p90 ≤ 500 ms; full-200 p90 ≤ 1,200 ms (docs/PERFORMANCE_OBSERVABILITY.md).
A/B testing strategy
The knobs are cheap to vary server-side because clients treat sizes as parameters:
- First-phase size (8 vs 12 vs 16): goal on Comments TTI and early scroll depth; guard on payload size and cache hit rate.
- Prefetch dwell threshold (aggressiveness): goal on prefetched-TTI share and comments viewed; guard on wasted-prefetch ratio and data use on metered networks. This is the experiment with a real infra cost (Reddit’s 40k rps): run it with a cost curve, not just a UX curve.
- Depth cap / continue-thread threshold (10 vs 8 vs 12): goal on comments viewed per post and continue-thread click-through; guard on readability complaints and deep-thread interaction rates.
- Ranking variant inside the heap (score vs recency-boosted): goal on comment votes cast and replies posted; guard on total comments viewed.
Assignment must be viewer-bucketed, not post-bucketed (posts have wildly different tree shapes), and every variant reports the same fixed metric boundaries: a number without its boundary defined is not the same metric. Because sizes are cache keys, each variant needs its own pre-computed cache population; that’s the hidden infra cost of experimenting here, and why variants should be few and long-lived.
What landed
Server-built best-first trees at fixed (8, 200) sizes, dwell-gated/coalesced prefetch, anti-flicker client merge, bounded account-scoped Room documents plus retained state, iterative tree operations, structure-only off-main flattening, and tree-aware progressive loading all remain in the shared codebase. Domain invariants run from :core:model common tests; controller/paging policies live in :core:client common tests, and the shared detail presentation has its own cross-target tests.
Next I’ll trace how the shared offline-first data layer makes these screens resilient without splitting Android and iOS behavior.