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.8 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.
TL;DR: why comments are hard
Comment thread is an arbitrarily deep, arbitrarily wide tree and can hold more nodes than a phone should download. Also they are mixed with media, mutated constantly (votes, replies, collapses), and rendered by a UI toolkit that wants a flat list. 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.
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 problem, precisely
- 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.
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: normalized, never a recursive blob: Room tables comment_threads, comment_nodes, post_headers, all account/post-scoped. Encoding uses iterative preorder traversal; decoding is iterative bottom-up, so a deep thread cannot consume the call stack.
Render: a flat List<CommentRow> where each row carries renderDepth:
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: the feed calls CommentsRepository.prefetch(postId) after dwell suggests intent, so tap-through renders without touching the network. Reddit’s published cost for the same feature: ~40k extra requests/second. Prefetching isn’t free, which is why dwell gates it.
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#L83-L169: 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.
Two-stage UiState. The flatten is expensive; loading flags churn constantly. So derivation happens in two combine stages: flag churn re-executes cheap assembly, never the flatten, and the flatten runs off the main thread (CommentsViewModel.kt#L105-L133). 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 prefetch, anti-flicker client merge, normalized Room L2 + bounded LRU L1, iterative everything, two-stage state derivation, and tree-aware progressive loading, with each behavior pinned by a JVM test (12 test files). The full rationale lives in comments/README.md.