Reading a feed.
The write path left a list of 500 post IDs in Ines's feed. Turning that into a screenful of posts still takes work: merge in the six big accounts she follows, drop deleted posts and muted authors, fetch 20 post bodies, authors and counts in one round of cache lookups, and return a cursor that stays correct while new posts keep arriving. When the list is missing, rebuild it on the spot.
Builds on Fan-out on write and Caching patterns.
Requirements.
The read path of Chorus's Following tab: one page of posts, newest first. The For you tab adds a ranking step on top of this (next topic).
Functional requirements
Non-functional requirements
Capacity estimates
What one page costs
- Daily active users
- 200Massumption
- Page loads per user per day
- 12assumption (opens, scrolls and refreshes)
- Peak-to-average ratio
- 3×evening peak; assumption
- Posts per page
- 20
- Post JSON without media bytes
- ~1.2 KBtext, author, media URLs, counts
- Post-cache hit rate
- 95%assumption
- Large (pull-mode) accounts a reader follows
- 6average; assumption
- App opens per user per day
- 4assumption; ~1% find no feed list (cold)
- Minutes in the app per user per day
- 20assumption; one "anything new?" poll a minute
- Pages per second one feed-service node serves
- ~2,000assumption; mostly waiting on caches
- Page requests200M × 12 = 2.4B/day ÷ 86,400 = 27.8K/s; × 3~83K/s peakfrom Daily active users, Page loads per user per day and Peak-to-average ratio
- Post-cache gets83K × 20~1.67M/sfrom Page requests and Posts per page
- Reads that fall through to the post store1.67M × 5%~83K/sfrom Post-cache gets and Post-cache hit rate
- Large-account reads83K × 6~500K/sfrom Page requests and Large (pull-mode) accounts a reader follows · Only ~80K accounts are large. Their newest 20 posts are 80K × 20 × 20 B = 32 MB, so every feed-service node keeps them in memory and these reads never leave the process.
- "Anything new?" polls200M × 20 = 4B/day ÷ 86,400 = 46K/s; × 3~139K/s peakfrom Daily active users, Minutes in the app per user per day and Peak-to-average ratio · More than page loads, which is why a poll must be one ZCOUNT and nothing else.
- Cold rebuilds200M × 4 × 1% = 8M/day ÷ 86,400 = 93/s; × 3~280/s peakfrom Daily active users, App opens per user per day and Peak-to-average ratio · Each reads the newest posts of 200 followees, so ~280 × 200 = ~56K post-store reads/s at peak.
- Response bytes83K × 20 × 1.2 KB = 83K × 24 KB~2 GB/sfrom Page requests, Posts per page and Post JSON without media bytes
- Feed-service nodes83K ÷ 2,000 = 42; ÷ 0.7 for headroom~60 nodesfrom Page requests and Pages per second one feed-service node serves
- Hydration, not the feed list, is the expensive part: one feed-list read becomes 20 post lookups, ~14 author lookups and 20 counts.
- Large accounts are few enough to cache everywhere, which is what makes pulling them at read time cheap.
Where the read load lands at peak
Data
| Read | reads/s (req/s) |
|---|---|
| Feed list ranges | 83,000 |
| New-post counts | 139,000 |
| Large accounts | 500,000 |
| Post cache | 1,670,000 |
| Author cache | 1,170,000 |
| Counter service | 1,670,000 |
| Post store | 139,000 |
High-level design.
One service does the read. It touches the feed store once, then answers everything else from memory and caches in a single parallel round.
Reading a feed
Components
| Component | Responsibility | Owns |
|---|---|---|
| Feed service | Reads the list, merges, filters, hydrates and signs the cursor. Stateless apart from small in-memory tables it can rebuild. | recent_by_author, filters:{user_id} |
| Feed store | One capped sorted set of post references per active user, newest first. The read path only ranges and counts it, and writes back rebuilt lists. | feed:{user_id} |
| Post cache | Post bodies and author profiles by ID, shared by every reader. A post lives here once, however many feeds point at it. | post:{id}, user:{id} |
| Counter service | Like and reply counts; a slow answer is dropped rather than waited for. | counts by post_id |
| Social graph | Who the reader follows, mutes and blocks; followees only on a cold rebuild. | follows, mutes, blocks |
| Post store | Source of truth for posts and author timelines; reached on cache misses, own-post merges and rebuilds. | posts, author_posts |
| Post events | Tailed for the ~80K large accounts' new posts and for deletions, so both are known in memory within a second or two. | post.created, post.deleted |
Ines opens Chorus
- Ines's app → Feed service: GET /v1/feed?limit=20 (first page)
- Feed service → Feed store: ZRANGE feed:ines … LIMIT 0 40
- Feed store → Feed service (reply): 40 refs (2× over-fetch)
- Note over Feed service: Merge: 40 + 9 large-account posts = 49
- Note over Feed service: Filter 4 (deleted, muted, dupe); keep 20
- Feed service → Post cache: multi-get 20 posts + 14 authors
- Post cache → Feed service (reply): 33 hits, 1 miss (from post store)
- Feed service → Counter service: 20 counts (in parallel with 6)
- Counter service → Feed service (reply): 20 counts
- Feed service → Ines's app (reply): 200 { posts, next_cursor } ~33 ms
Merging three sorted sources
- 1 item, Not merged yet, value 10:04 Tomas
- 1 item, Not merged yet, value 10:01 Dara
- 1 item, Not merged yet, value 09:58 club
- 1 item, Not merged yet, value 09:52 Dara
- head, pointer at 0.5
- 1 item, Not merged yet, value 10:03
- 1 item, Not merged yet, value 09:40
- head, pointer at 0.5
- 1 item, Not merged yet, value 10:05
- 1 item, Not merged yet, value 10:02
- 1 item, Not merged yet, value 09:59
- 1 item, Not merged yet, value 09:56
- head, pointer at 0.5
- 1 item, Empty page slot, value 10:05 metro
- 1 item, Empty page slot, value 10:04 Tomas
- 1 item, Empty page slot, value 10:03 Kai
- 1 item, Empty page slot, value 10:02 metro
- 1 item, Empty page slot, value 10:01 Dara
- 1 item, Empty page slot, value 09:59 metro
- Not merged yet
- Taken for the page
- Empty page slot
As it starts. 6 steps follow.
Where one page's time goes
Timeline as a list
Where one page's time goes: 7 lanes, from 0 ms to 35 ms.
- 0–2 ms · API gateway · route
- 2–33 ms · Feed service · GET /v1/feed
- 3–7 ms · Feed store · 40 refs
- 7–9 ms · Merge + filter · merge
- 9–17 ms · Post cache · 20 posts + 14 authors
- 9–21 ms · Counters · 20 counts
- 17–27 ms · Post store · 1 post miss
- 31 ms · Feed service · 200 + next_cursor (ok)
The same page for a returning reader
Timeline as a list
The same page for a returning reader: 8 lanes, from 0 ms to 110 ms.
- 0–2 ms · API gateway · route
- 2–97 ms · Feed service · GET /v1/feed (rebuild)
- 6–18 ms · Social graph · followees
- 6 ms · Feed store · key missing (error)
- 18–68 ms · Post store · 200 author timelines
- 68–72 ms · Merge · merge 1,000 → 500
- 72–82 ms · Post cache · posts
- 72–85 ms · Counters · counts
- 94 ms · Feed service · page served (ok)
- 95–105 ms · Feed store · write back
Data model.
The read path owns almost no data. It reads the feed list and a few caches, keeps two small tables in memory, and hands out a cursor it can check later.
Written by fan-out (previous topic); the read path ranges it by score and counts newer entries, both O(log n).
| Column | Type | Key | Note |
|---|---|---|---|
| user_id | id | partition | in the key; one user's list lives on one shard |
| score | double | clustering desc | the post ID's millisecond timestamp |
| member | text (post_id:author_id) | author_id lets filters run before hydration |
Small enough to copy onto every node, read on every page. Rebuilt from post events and the post store at start-up.
| Column | Type | Key | Note |
|---|---|---|---|
| author_id | id | primary | |
| post_ids | list<id> | newest 20, from post.created | |
| updated_at | timestamp |
| Column | Type | Key | Note |
|---|---|---|---|
| post_id | id | primary |
| Column | Type | Key | Note |
|---|---|---|---|
| user_id | id | primary | |
| muted_ids | set<id> | ||
| blocked_ids | set<id> | both directions |
One shared copy of each post and profile, so an edit or delete shows everywhere at once.
| Column | Type | Key | Note |
|---|---|---|---|
| post_id | id | primary | |
| author_id | id | ||
| text | text | ||
| media_keys | list<text> | ||
| deleted | bool | tombstone; the last line of defence |
| Column | Type | Key | Note |
|---|---|---|---|
| user_id | id | primary | |
| name | text | ||
| avatar_key | text | ||
| verified | bool |
| Query | Uses | How |
|---|---|---|
| Next 40 refs older than the cursor | feed:{user_id} | ZRANGE key <cursor ms> -inf BYSCORE REV LIMIT 0 40 (inclusive bound), then drop members whose post_id >= before; same-millisecond ties are ordered by member |
| Newest posts from the reader's 6 large accounts | recent_by_author | local hash lookups, no network |
| Is this ref shown to this reader? | * | author_id against filters:{user_id}; post_id against recently_deleted |
| Bodies and authors for 20 IDs | post:{id} | one multi-get per cache shard, in parallel |
| Anything newer than the top of the page? | feed:{user_id} | ZCOUNT key (<newest ms> +inf, plus the in-memory large-account table |
Why the score is a timestamp, not the post ID
The score is the post ID's millisecond timestamp (fan-out-on-write explains why not the ID itself). The cursor keeps the full ID, so the seek uses an inclusive bound at the cursor's millisecond and then drops members at that millisecond that are not older. An exclusive bound would skip, for good, any post that shares the cursor post's millisecond but sorts after it.
What's inside a cursor
| Field | Example | Why |
|---|---|---|
| v | 1 | Lets the server change the format; old cursors stay readable or are rejected cleanly. |
| before | 2105236511271178247 | The last post ID served. The next page is "older than this" in every source, because every source sorts by the same ID; no offset, and no per-source positions. |
| mode | following | Chronological here; a ranked For you session carries a session ID instead (ranking topic). |
| sig | HMAC-SHA256, 16 bytes | Base64url (it rides in a query string) and opaque, as Slack's cursors are; we also sign it (our choice) so a tampered cursor is a clean 400. |
Interface.
Two calls. One fetches a page; the other only counts what is newer, so an open app can ask every minute without costing a page.
One page, newest first. Leave out cursor for the first page; pass back next_cursor for the next.
How many posts are newer than since, capped at 99. Called when the app comes to the foreground and every 60 s while it is open.
| HTTP | Type | Body code | Client behaviour |
|---|---|---|---|
| 400 | error | invalid_cursor | Malformed or the signature fails. Drop the cursor and load the first page. |
| 410 | error | cursor_expired | The ranked session it points at is gone (For you tab). Start a new session from the top. |
| 429 | retry | rate_limited | Too many calls from one session. Back off per Retry-After; keep showing the current page. |
Optimizations.
Four changes that keep a page to one feed-store trip and one round of cache lookups, and cover the readers the write path leaves out.
| Failure | Impact | Detection | Mitigation | Meanwhile |
|---|---|---|---|---|
| A feed-store shard is down4Feed store | Readers on it have no list | Redis errors and timeouts per shard | The replica is promoted; if the shard and its replica are both lost, treat those readers as cold feeds and rebuild under the per-user lock | A slower first page (~100 ms), flagged degraded, for about 0.3% of readers (one shard of 313) |
| A post-cache node restarts empty5Post cache | Its keys all miss; post-store reads for that slice jump | Hit rate and post-store QPS alarms | Leases so one request per key goes to the store and others wait; route the dead node's keys to a small gutter pool meanwhile, and warm a new cluster from a warm one (Facebook's cold-cluster warmup) | Slower pages for a few minutes |
| Counter service is slow6Counter service | Hydration would wait on it | p99 of the counts call | Stop waiting at the cutoff and return the page without counts | Counts appear on the next refresh |
| Social graph is slow7Social graph | Fresh mute and block lists can't be read | Timeouts on the filters call | Keep using the cached lists (up to 60 s stale); a block is also enforced on the write side | A just-muted account may show for up to a minute |
| The feed service falls behind on post events9Post events | Large accounts' newest posts and recent deletes are late in memory | Consumer lag per node | Posts still carry the post-cache tombstone check; alert past 10 s of lag | Large accounts' posts show seconds late |
Trade-offs.
The chosen option is first; the others stay visible so the reasoning can be checked.
- Pro:Stable while new posts arrive at the top
- Pro:O(log n) seek in the sorted set
- Pro:One position covers every merged source
- Con:No jumping to page 7
- Con:Needs signing and a version so its format can change
New posts shift every offset, so the reader sees repeats; The merge and filters must restart from the newest post for every page; Slack moved off offsets for both reasons
Entries merged to serve page p
- Offset
- Cursor
Data
| Page number | Offset | Cursor |
|---|---|---|
| 1 | 40 | 40 |
| 5 | 200 | 40 |
| 10 | 400 | 40 |
| 15 | 600 | 40 |
| 20 | 800 | 40 |
| 25 | 1,000 | 40 |
- Pro:One copy of each post
- Pro:Edits and deletes visible at once
- Pro:Feed entries stay small references (20 B logical, ~100 B in Redis)
- Con:20 post lookups (plus authors and counts) per page
About 50× the raw bytes per entry (a ~1 KB post against a 20 B reference; fan-out-on-write sizes the store); Edits and deletes must be fanned out again; Every fan-out write gets heavier
- Pro:One small call a minute per open app
- Pro:The list never jumps under the reader; Pinterest likewise keeps what a reader has seen apart from unseen items and materializes new ones only on refresh
- Pro:Works through any proxy
- Con:Up to 60 s late
- Con:~139K polls/s at peak, even when nothing changed
~8M open connections at peak (200M × 20 min ÷ 1,440 × 3) for a feed that tolerates a minute; Needs its own fleet and reconnect logic (pub-sub/push-to-clients)
- Pro:No memory spent on lists nobody reads
- Pro:Only ~1% of app opens pay for it
- Con:A slower first page for them (~100 ms, under 600 ms at p99)
- Con:Needs a per-user lock against duplicate rebuilds
Fan-out and memory for users who may never come back; Grows with total sign-ups rather than active readers