News feedReading a feed

100%

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.

Intermediate25 minUpdated 30 Sept 2026

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

#1Opening the app shows the newest 20 posts from everyone the reader follows, large accounts included.
#2Scrolling loads the next 20 with no duplicates and no gaps, even while new posts arrive.
#3The app can ask "anything new?" cheaply and show a "5 new posts" pill instead of jumping the list.
#4Deleted posts, and posts from muted or blocked accounts, never appear.
#5Your own new post shows at the top of your feed immediately.
#6A reader returning after months gets a full feed on the first open.

Non-functional requirements

NFR-01A page returns in under 200 ms at p99 on the server when the feed list exists, and under 600 ms when it must be rebuilt.
NFR-0299.95% availability. A page without counts, or a slightly stale one, beats an error screen.
NFR-03Reads outnumber writes about 50 to 1, so the read path must not do per-post work proportional to follower counts.

Capacity estimates

Page requests per second, peak
83K
200M DAU × 12 pages a day, ×3 evening peak
Post lookups per second, peak
1.7M
20 posts per page, all from cache
Response egress per second
2 GB
20 posts × ~1.2 KB; media come from the CDN

What one page costs

Assumptions
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
Working
  1. 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
  2. Post-cache gets83K × 20~1.67M/sfrom Page requests and Posts per page
  3. Reads that fall through to the post store1.67M × 5%~83K/sfrom Post-cache gets and Post-cache hit rate
  4. 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.
  5. "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.
  6. 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.
  7. Response bytes83K × 20 × 1.2 KB = 83K × 24 KB~2 GB/sfrom Page requests, Posts per page and Post JSON without media bytes
  8. Feed-service nodes83K ÷ 2,000 = 42; ÷ 0.7 for headroom~60 nodesfrom Page requests and Pages per second one feed-service node serves
What it means
  • 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

Where the read load lands at peakHydration dominates. Post, author and count lookups each run at over 1M/s, about 20 times the feed-list reads.0200k400k600k800k1M1.2M1.4M1.6M1.8MFeed list rangesNew-post countsLarge accountsPost cacheAuthor cacheCounter servicePost store83k req/s139k req/s500k req/s1.67M req/s1.17M req/s1.67M req/s139k req/sReadReads per second (req/s)Where the read load lands at peakHydration dominates. Post, author and count lookups each run at over 1M/s, about 20 times the feed-list reads.0800k1.6MFeed list rangesNew-post countsLarge accountsPost cacheAuthor cacheCounter servicePost store83k req/s139k req/s500k req/s1.67M req/s1.17M req/s1.67M req/s139k req/sReadReads per second (req/s)
Per second at the 3× peak, from the estimate above. Large accounts are merged from each node's memory; author profiles live in the post cache (~14 distinct per page). The post store sees cache misses plus cold rebuilds.
Data
Readreads/s (req/s)
Feed list ranges83,000
New-post counts139,000
Large accounts500,000
Post cache1,670,000
Author cache1,170,000
Counter service1,670,000
Post store139,000
Was this section helpful?

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

Reading a feed. The numbered component cards that follow describe each part.
Reading a feedComponents: 1. Chorus apps (iOS, Android and web. Scroll, pull to refresh, poll for new posts.), 2. API gateway (Authenticates the session, rate-limits, and routes feed calls to the feed service.), 3. Feed service (The read path. Fetches the feed list, merges large accounts and the reader's own posts, filters, hydrates and pages.), 4. Feed store (Redis cluster holding one capped sorted set of post references per active user. Written by fan-out, read here.), 5. Post cache (Memcached-style cache of post bodies and author profiles by ID used to hydrate.), 6. Counter service (Like and reply counts per post the sharded-counters system owns it.), 7. Social graph (Followees, mutes and blocks of a user, paged its own sharded store (a black box here).), 8. Post store (Posts by ID and each author's posts newest first (the author timeline). Source of truth.), 9. Post events (Kafka topic of post.created and post.deleted, partitioned by author. Every feed-service node consumes the whole topic (~1.75K events/s at peak) and keeps only large-account posts and deletes.).

Sources of truth

Caches

Read path

GET /v1/feed

range

large posts + deletes

mutes, blocks

posts + authors

counts

own posts; rebuilds

on miss

2API gateway
auth
rate limit

3Feed service
list + 6 large + own posts
drop mutes, deletes, dupes
one parallel round
mergefilterhydrate

4Feed store
500 refs per user

5Post cache
posts
authors by ID

7Social graph
followees
mutes
blocks

8Post store
author timelines
posts

9Post events
large-account posts
deletes

1Chorus apps
scroll
pull to refresh

6Counter service
likes
replies

Components

ComponentResponsibilityOwns
Feed serviceReads 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 storeOne 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 cachePost 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 serviceLike and reply counts; a slow answer is dropped rather than waited for.counts by post_id
Social graphWho the reader follows, mutes and blocks; followees only on a cold rebuild.follows, mutes, blocks
Post storeSource of truth for posts and author timelines; reached on cache misses, own-post merges and rebuilds.posts, author_posts
Post eventsTailed 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 opens Chorus, as an ordered list of steps:
Ines opens Chorus10 steps between Ines's app, Feed service, Feed store, Post cache, Counter service. The steps are listed as text after the diagram.Counter servicePost cacheFeed storeFeed serviceInes's appMerge: 40 + 9 large-account posts = 49Filter 4 (deleted, muted, dupe); keep 20GET /v1/feed?limit=20 (first page)1ZRANGE feed:ines … LIMIT 0 40240 refs (2× over-fetch)3multi-get 20 posts + 14 authors433 hits, 1 miss (from post store)520 counts (in parallel with 6)620 counts7200 { posts, next_cursor } ~33 ms8
  1. Ines's app → Feed service: GET /v1/feed?limit=20 (first page)
  2. Feed service → Feed store: ZRANGE feed:ines … LIMIT 0 40
  3. Feed store → Feed service (reply): 40 refs (2× over-fetch)
  4. Note over Feed service: Merge: 40 + 9 large-account posts = 49
  5. Note over Feed service: Filter 4 (deleted, muted, dupe); keep 20
  6. Feed service → Post cache: multi-get 20 posts + 14 authors
  7. Post cache → Feed service (reply): 33 hits, 1 miss (from post store)
  8. Feed service → Counter service: 20 counts (in parallel with 6)
  9. Counter service → Feed service (reply): 20 counts
  10. Feed service → Ines's app (reply): 200 { posts, next_cursor } ~33 ms

Merging three sorted sources

Ines's feed list (pushed)4Feed store
  1. 1 item, Not merged yet, value 10:04 Tomas
  2. 1 item, Not merged yet, value 10:01 Dara
  3. 1 item, Not merged yet, value 09:58 club
  4. 1 item, Not merged yet, value 09:52 Dara
  • head, pointer at 0.5
Kai Ren (pulled)3Feed service
  1. 1 item, Not merged yet, value 10:03
  2. 1 item, Not merged yet, value 09:40
  • head, pointer at 0.5
@metro_alerts (pulled)3Feed service
  1. 1 item, Not merged yet, value 10:05
  2. 1 item, Not merged yet, value 10:02
  3. 1 item, Not merged yet, value 09:59
  4. 1 item, Not merged yet, value 09:56
  • head, pointer at 0.5
Page (first 6 slots)
  1. 1 item, Empty page slot, value 10:05 metro
  2. 1 item, Empty page slot, value 10:04 Tomas
  3. 1 item, Empty page slot, value 10:03 Kai
  4. 1 item, Empty page slot, value 10:02 metro
  5. 1 item, Empty page slot, value 10:01 Dara
  6. 1 item, Empty page slot, value 09:59 metro
  • Not merged yet
  • Taken for the page
  • Empty page slot
Start

As it starts. 6 steps follow.

Every source is already sorted newest first by post ID, so a merge only compares the current heads and takes the newest. Times stand in for the time-ordered IDs; @metro_alerts is a transit bot with 400K followers, so it is pulled, not pushed.

Where one page's time goes

Timeline as a list

Where one page's time goes: 7 lanes, from 0 ms to 35 ms.

  1. 0–2 ms · API gateway · route
  2. 2–33 ms · Feed service · GET /v1/feed
  3. 3–7 ms · Feed store · 40 refs
  4. 7–9 ms · Merge + filter · merge
  5. 9–17 ms · Post cache · 20 posts + 14 authors
  6. 9–21 ms · Counters · 20 counts
  7. 17–27 ms · Post store · 1 post miss
  8. 31 ms · Feed service · 200 + next_cursor (ok)
Ines's first page on a warm path takes ~33 ms of server time; the post and count calls run in parallel, so the page costs the slowest of them, not their sum. Hydration gets 70 ms from when it starts (here until 79 ms, off this axis); anything not back by then is left out, counts first, and the page is flagged degraded: true. Durations are illustrative.

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.

  1. 0–2 ms · API gateway · route
  2. 2–97 ms · Feed service · GET /v1/feed (rebuild)
  3. 6–18 ms · Social graph · followees
  4. 6 ms · Feed store · key missing (error)
  5. 18–68 ms · Post store · 200 author timelines
  6. 68–72 ms · Merge · merge 1,000 → 500
  7. 72–82 ms · Post cache · posts
  8. 72–85 ms · Counters · counts
  9. 94 ms · Feed service · page served (ok)
  10. 95–105 ms · Feed store · write back
No feed list, so the service rebuilds one from the reader's top 200 followees before serving: about 1% of app opens. The 200 timeline reads go 20 at a time, 10 rounds of ~5 ms. Hydration starts at 72 ms, so its 70 ms deadline falls at 142 ms, off this axis; counts at 85 ms are well inside it. The list is written back after the page is served.
Was this section helpful?

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.

Redis clusterOwned by 4Feed store

Written by fan-out (previous topic); the read path ranges it by score and counts newer entries, both O(log n).

feed:{user_id}TTL 30 days without a readSorted set capped at 500 entries.
ColumnTypeKeyNote
user_ididpartitionin the key; one user's list lives on one shard
scoredoubleclustering descthe post ID's millisecond timestamp
membertext (post_id:author_id)author_id lets filters run before hydration
In-process memory (every feed-service node)Owned by 3Feed service

Small enough to copy onto every node, read on every page. Rebuilt from post events and the post store at start-up.

recent_by_author80K large accounts × 20 IDs × 20 B = 32 MB.
ColumnTypeKeyNote
author_ididprimary
post_idslist<id>newest 20, from post.created
updated_attimestamp
recently_deletedTTL 48 hAssumes ~1% of 50M daily posts are deleted; 1M IDs × 8 B = 8 MB.
ColumnTypeKeyNote
post_ididprimary
filters:{user_id}TTL 60 s
ColumnTypeKeyNote
user_ididprimary
muted_idsset<id>
blocked_idsset<id>both directions
MemcachedOwned by 5Post cache

One shared copy of each post and profile, so an edit or delete shows everywhere at once.

post:{id}TTL 24 h
ColumnTypeKeyNote
post_ididprimary
author_idid
texttext
media_keyslist<text>
deletedbooltombstone; the last line of defence
user:{id}TTL 1 h
ColumnTypeKeyNote
user_ididprimary
nametext
avatar_keytext
verifiedbool
Access patterns
QueryUsesHow
Next 40 refs older than the cursorfeed:{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 accountsrecent_by_authorlocal 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 IDspost:{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

FieldExampleWhy
v1Lets the server change the format; old cursors stay readable or are rejected cleanly.
before2105236511271178247The 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.
modefollowingChronological here; a ranked For you session carries a session ID instead (ranking topic).
sigHMAC-SHA256, 16 bytesBase64url (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.
Was this section helpful?

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.

1GET/v1/feed?tab=following&limit=20&cursor=eyJ2IjoxLCJi…

One page, newest first. Leave out cursor for the first page; pass back next_cursor for the next.

Request
Headers
Authorization: Bearer <session token>
Response200
{
"posts": [
{
"post_id": "2105236511271178247",
"author": {
"id": "77120",
"name": "Dara",
"avatar": "https://img.chorus.example/a/77120.jpg"
},
"text": "Sourdough's out at 7.",
"media": [
{
"type": "photo",
"url": "https://img.chorus.example/p/2105236511271178247/0.jpg"
}
],
"counts": { "likes": 41, "replies": 3 },
"created_at": "2026-09-30T10:01:12Z"
}
],
"next_cursor": "eyJ2IjoxLCJiZWZvcmUiOiIyMTA1…",
"newest_id": "2105237479195054082",
"degraded": false
}
IDs are strings because 64-bit integers lose precision in JavaScript. newest_id is what the app polls with. degraded: true is not an error: the page was served without counts, without large accounts, or from a rebuilt list; show it, and the next page may be complete.
2GET/v1/feed/new?since=2105237479195054082

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.

Request
Response200
{ "count": 5 }
One ZCOUNT on one key plus a scan of the in-memory large-account table. Never a feed fetch; the list moves only when the reader taps the pill.
{ "error": { "code": "invalid_cursor", "message": "This cursor can't be read." } }
HTTPTypeBody codeClient behaviour
400error
invalid_cursor
Malformed or the signature fails. Drop the cursor and load the first page.
410error
cursor_expired
The ranked session it points at is gone (For you tab). Start a new session from the top.
429retry
rate_limited
Too many calls from one session. Back off per Retry-After; keep showing the current page.
Was this section helpful?

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.

Over-fetch, then filter
Filters remove about 10% of refs (deletes, mutes, blocks, duplicates). Reading 40 refs for a 20-post page means one feed-store trip is almost always enough; if filtering leaves fewer than 20, read the next 40. Duplicates come from merging sources that can hold the same post (an account that moved between push and pull, or your own post once fan-out catches up); dropping repeated post_ids during the merge removes them.
One parallel round of lookups
Posts, authors and counts go out together as multi-gets, one per cache shard. Authors are deduplicated first: 20 posts come from ~14 authors, so 14 profile lookups, not 20. Page time is the slowest of three calls, not their sum. The same idea is how Facebook's web servers fetch hundreds of memcache items per page.
Rebuild cold feeds on read
A reader back after 30+ days has no list. Take the 200 followees they engaged with most, read each one's newest 5 posts from author_posts (20 reads in flight, ~10 rounds of ~5 ms), merge 1,000 posts down to 500, serve page one and write the list back. The key is created first with a placeholder so fan-out appends made during the rebuild land, and the 500 refs are then ZADDed into that same key, never swapped in by RENAME (fan-out-on-write traces this race). At 1% of 800M daily app opens (200M × 4) that is ~93/s, ~280/s at peak, or ~56K author-timeline reads/s. A per-user lock (SET NX, 2 s) stops two devices rebuilding at once; the loser waits and reads the result.
Your own post, at once
Fan-out may lag a few seconds, but Dara must see her post the moment she posts it. The feed service merges the reader's own posts from the last 60 s (their author timeline, cached per user) into page one, and dedupes if fan-out catches up. That is read-your-writes for the one person who would notice.
FailureImpactDetectionMitigationMeanwhile
A feed-store shard is down4Feed storeReaders on it have no listRedis errors and timeouts per shardThe replica is promoted; if the shard and its replica are both lost, treat those readers as cold feeds and rebuild under the per-user lockA slower first page (~100 ms), flagged degraded, for about 0.3% of readers (one shard of 313)
A post-cache node restarts empty5Post cacheIts keys all miss; post-store reads for that slice jumpHit rate and post-store QPS alarmsLeases 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 serviceHydration would wait on itp99 of the counts callStop waiting at the cutoff and return the page without countsCounts appear on the next refresh
Social graph is slow7Social graphFresh mute and block lists can't be readTimeouts on the filters callKeep using the cached lists (up to 60 s stale); a block is also enforced on the write sideA just-muted account may show for up to a minute
The feed service falls behind on post events9Post eventsLarge accounts' newest posts and recent deletes are late in memoryConsumer lag per nodePosts still carry the post-cache tombstone check; alert past 10 s of lagLarge accounts' posts show seconds late
Was this section helpful?

Trade-offs.

The chosen option is first; the others stay visible so the reasoning can be checked.

01
How the next page is addressed
Chosen:Cursor on the last post ID
  • 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
Downside we accept:
  • Con:No jumping to page 7
  • Con:Needs signing and a version so its format can change
Ruled out:Offset (page=2)

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
Entries merged to serve page pAn offset re-merges everything above the page, so page 25 costs 25 times page 1; a cursor costs the same on every page.02004006008001k510152025OffsetCursorEntries merged and filteredPage numberEntries merged to serve page pAn offset re-merges everything above the page, so page 25 costs 25 times page 1; a cursor costs the same on every page.02004006008001k510152025OffsetCursorEntries merged and filteredPage number
With 40 refs read per page. Redis can seek by rank cheaply, but merged and filtered sources cannot, so an offset has to rebuild the top of the feed each time.
Data
Page numberOffsetCursor
14040
520040
1040040
1560040
2080040
251,00040
02
Where post bodies come from
Chosen:References in the feed, bodies from a shared post cache
  • 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)
Downside we accept:
  • Con:20 post lookups (plus authors and counts) per page
Ruled out:Whole posts copied into every feed

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

03
Telling the reader about new posts
Chosen:Poll a cheap count, show a pill
  • 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
Downside we accept:
  • Con:Up to 60 s late
  • Con:~139K polls/s at peak, even when nothing changed
Ruled out:Push over a live connection

~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)

04
Readers with no precomputed list
Chosen:Rebuild on read from top followees
  • Pro:No memory spent on lists nobody reads
  • Pro:Only ~1% of app opens pay for it
Downside we accept:
  • Con:A slower first page for them (~100 ms, under 600 ms at p99)
  • Con:Needs a per-user lock against duplicate rebuilds
Ruled out:Keep every user's list warm

Fan-out and memory for users who may never come back; Grows with total sign-ups rather than active readers

Was this section helpful?
Builds on this
Ranking the feed
Read next