ReadThat 2: The SDUI Feed
Why the feed is server-driven and post detail is not: the wire model, flattening into a LazyColumn, forward compatibility via Unknown cells, what SDUI buys (TTI, out-of-band updates, client simplicity) and what it costs (type safety, interactivity, caching).
Part 2 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.
Why SDUI
Reddit’s feed team described the motivation in Evolving Reddit’s Feed Architecture and the follow-up Rewriting Home Feed on Android & iOS: instead of one fat polymorphic Post object the client must interpret, the server sends the description of the exact UI elements the client will render, and controls their type and order.
What that buys:
- Reduced TTI. Feed payloads carry only render cells, no detail-only fields. ReadThat’s feed page budget is 64 KiB decoded at p50. Smaller payloads decode faster and hit the first content frame sooner.
- Out-of-band updates. A new feed unit (a new promotion format, a carousel variant) ships when the server ships it, not after three app-store review queues and a rollout curve.
- Client simplicity. The client’s job is to render, not to decide. Ranking, experimentation, and unit composition live in one place, server-side.
The wire model
A feed page is a list of groups; each group is an ordered array of cells, the atomic renderable pieces:
@Serializabledata class WireFeedPage( val groups: List<WireGroup>, /** Opaque cursor for the next page. Null means end-of-feed. */ val nextCursor: String?,)
@Serializabledata class WireGroup( val groupId: String, val cells: List<WireCell>,)
@Serializablesealed interface WireCell { val cellId: String
@Serializable @SerialName("metadata") data class Metadata(...) : WireCell
@Serializable @SerialName("title") data class Title(...) : WireCell
@Serializable @SerialName("video") data class Video(...) : WireCell
/** A cell type this client build does not understand. */ @Serializable @SerialName("unknown") data class Unknown( override val cellId: String, val typeName: String, ) : WireCell}Unknown is the load-bearing member. It’s what a current client produces when a newer server sends a cell type this build has never heard of. Keeping it in the type system, rather than throwing during parse, is what makes the feed forward-compatible. Its typeName feeds telemetry, so the platform team can measure how much of the feed each app version fails to render: the signal that says when it’s safe to drop an old build.
Flattening
The server sends two levels. A LazyColumn wants one:
SERVER (wire model) CLIENT (render list)
[ Group("post_1", [ [ Metadata key="post_1/meta" Metadata, Title, Image, Title key="post_1/title" ActionBar ]), Image key="post_1/img" Group("post_2", [...]) ] ActionBar key="post_1/actions" Divider key="post_1/divider" ... ]object FeedFlattener { fun flatten( groups: List<WireGroup>, registry: CellConverterRegistry = CellConverterRegistry(), appendDividers: Boolean = true, ): RenderList { val items = ArrayList<CellUi>(groups.sumOf { it.cells.size } + groups.size) val dropped = LinkedHashMap<String, Int>()
for (group in groups) { var renderedInGroup = 0 for (cell in group.cells) { val key = keyFor(group.groupId, cell.cellId) val ui = registry.convert(cell, key) if (ui != null) { items += ui; renderedInGroup++ } else { /* count dropped types for telemetry */ } } // A group that rendered nothing must not leave a stray divider — // otherwise a client that can't parse a new post type shows a feed // of empty separators instead of degrading invisibly. if (appendDividers && renderedInGroup > 0) { items += CellUi.GroupDivider(key = keyFor(group.groupId, "divider")) } } return RenderList(items = items, droppedCellTypes = dropped) }}Two details carry the weight:
- Composite stable keys.
LazyColumnuses keys for scroll restoration and recomposition scoping. The samecellIdcan legitimately appear in two groups (a shared promoted cell), so the key isgroupId/cellId. - Pure function.
List<WireGroup> -> RenderList. No Android, no IO: trivially testable, trivially movable off the main thread.
The wire model above, running: every post in this capture is a group of server-described cells flattened into one LazyColumn.
The cons, honestly
SDUI is not free, and ReadThat runs into each cost:
- You lose type safety at the wire. The sealed
WireCellhierarchy restores it per version, but the compiler can’t know what next month’s server sends: henceUnknown, the converter registry, and dropped-cell telemetry as a permanent tax. - Interactivity is harder. A vote button described by the server still needs client behavior: optimistic state, outboxes, animation. ReadThat solves it by overlaying viewer state (
item_state) onto the cached payload at merge time instead of rewriting server blobs; theActionBarcell documents this seam directly (Wire.kt#L179-L194). Interaction-heavy screens fight the model constantly. - Caching is more complicated. You’re caching presentation, not entities. ReadThat stores groups as server blobs in
feed_groupskeyed by(accountId, feedId, sortIndex), with a unique index enforcing ordering, and keeps mutable viewer state in a separate joined table so an optimistic like doesn’t invalidate a cached page.
Where SDUI stops
The most useful design decision in the repo is a boundary: :feature:comments is deliberately not SDUI. The feed’s shape varies (new unit types ship constantly) while its interactions are shallow; post detail’s shape is stable while its interactions are deep: vote, reply, collapse, expand. Reddit runs the same split. Knowing where SDUI stops is worth more than applying it everywhere. That screen gets its own page.
The backend half (signed keyset cursors, rank snapshots, how a feed page is assembled in D1) is in part 6.
← Part 1: Client architecture · Next: Part 3: The media feed →