News feed
A user posts, and their followers see it in their home feed, newest first. The design question is when to do the work: when the post is written, when the feed is read, or a mix.
- 1Do the work at post time: fan out post IDs into a capped timeline per active reader. A feed read is then one sorted-set range.
- 2Do not push large accounts. Pull their newest posts at read time and merge. The worst post drops from millions of writes to none.
- 3Timelines hold IDs only and are a cache. Posts, follows and deletes live in Postgres, and any timeline can be rebuilt.
- 4IDs sort by time, so a cursor (the last ID seen) pages correctly while new posts arrive.
"Design the home feed of a social app: a user posts, their followers see it in order, at hundreds of millions of users."
| question | answer assumed |
|---|---|
| Which feed? | Home feed: posts of accounts I follow. Discovery is out of scope. |
| Order? | Newest first. Ranking is a follow-up. |
| How many users? | 500 million daily. 200 follows on average. |
| Largest account? | 100 million followers. |
| How fresh? | Followers see a post within 5 s. The author sees it at once. |
| Post size? | Text up to 500 bytes. Images and video by upload. |
| Deletes? | A deleted post must vanish from every feed at once. |
Before I draw anything, I confirm the feed type, the order, the scale and how fresh the feed must be.
| functional |
|---|
| Post text with media; delete a post. |
| Follow and unfollow an account. |
| Read the home feed, newest first, 20 posts a page, with a cursor. |
| non-functional | target |
|---|---|
| Feed read latency | p99 < 200 ms |
| Post to follower's feed | p99 < 5 s |
| Feed availability | 99.95% |
| Consistency | Eventual for followers; read-your-writes for the author |
| Reads per post | 5 B ÷ 100 M = 50 |
Feed reads outnumber posts 50 to 1, so I spend work at write time to make reads cheap.
| quantity | arithmetic | result |
|---|---|---|
| Feed loads | 500 M users × 10 a day = 5 B ÷ 86,400 | ≈ 58,000/s; 3× peak ≈ 174,000/s |
| Posts | 500 M × 0.2 a day = 100 M ÷ 86,400 | ≈ 1,200/s; peak ≈ 3,500/s |
| Timeline writes, push to all | 100 M posts × 200 followers = 20 B ÷ 86,400 | ≈ 231,000/s |
| Push to active followers only | × 0.40 (simulated: 40% of users active) | ≈ 93,000/s |
| Timeline cache | 500 M timelines × 128 IDs × 31.4 B (measured) | ≈ 2.0 TB of memory |
| Post storage | 100 M × 500 B a day | 50 GB a day ≈ 18 TB a year |
| Media | 10 M images × 200 KB a day | 2 TB a day, in object storage |
| Feed bandwidth | 58,000/s × 20 posts × 500 B | ≈ 4.6 Gbit/s, media excluded |
Inputs are the assumed answers in panel A. Bytes per entry measured on Redis in the lab; the active share comes from the simulation in panel H. Method: R2.
Feed loads are about 58,000 a second and posts about 1,200. Pushing every post to every follower is 231 thousand timeline writes a second, so who gets a push matters.
| call | for | retry safe by | success | errors |
|---|---|---|---|---|
| POST /v1/posts {body, media_ids} + Idempotency-Key | publish | the idempotency key | 201 + id | 400, 413 too long |
| DELETE /v1/posts/{id} | delete | DELETE is idempotent | 204 | 403, 404 |
| PUT /v1/me/following/{user_id} | follow | PUT is idempotent | 204 | 404 |
| DELETE /v1/me/following/{user_id} | unfollow | DELETE is idempotent | 204 | 404 |
| GET /v1/feed?cursor=&limit=20 | home feed | a read | 200 + posts, next_cursor | 400 bad cursor |
| POST /v1/media {type, bytes} | upload URL | the media id | 201 + media_id, upload_url | 413 |
- The cursor is the last post ID the client saw, encoded as an opaque string.
- The client uploads media straight to object storage with a presigned URL (B9), then posts the media ID.
The feed endpoint takes a cursor, not a page number. A post carries an idempotency key, so a retried request never posts twice.
| Redis key | holds | written by | read by |
|---|---|---|---|
| tl:{user} | Sorted set, at most 128 post IDs of pushed accounts. Score: the post's millisecond. | Fan-out workers | Feed reads |
| au:{author} | Sorted set, the author's own newest 128 posts | Fan-out workers | Reads of large accounts; rebuilds |
CREATE TABLE users (
id bigint PRIMARY KEY,
handle text NOT NULL UNIQUE,
follower_count bigint1 NOT NULL DEFAULT 0,
last_active_at timestamptz2 NOT NULL DEFAULT now()
);
CREATE TABLE follows (
follower_id bigint NOT NULL REFERENCES users (id),
followee_id bigint NOT NULL REFERENCES users (id),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX follows_followee ON follows (followee_id, follower_id);3
CREATE TABLE posts (
id bigint PRIMARY KEY4,
author_id bigint NOT NULL REFERENCES users (id),
body text NOT NULL,
media_key text,
deleted_at timestamptz5
);
CREATE INDEX posts_author ON posts (author_id, id DESC);- 1Kept with the user, so fan-out decides push or pull with one lookup.
- 2Fan-out writes only to followers seen in the last 30 days.
- 3The reverse direction: every follower of an author, in one index range.
- 4A time-ordered 64-bit ID: milliseconds, node, sequence. Sorting by ID sorts by time.
- 5A soft delete. Hydrate skips the row at once; the cleanup job removes the ID from timelines later.
Postgres holds users, follows and posts. Redis holds two sorted sets per user: the timeline others push into, and the user's own newest posts.
Step 1: Post
- Give the post a time-ordered ID, so IDs sort by time.
- Insert the post and a "post created" event in one transaction (an outbox).
- Return 201 with the ID. The author sees the post at once.
If it fails
The insert fails: return 5xx; the client retries with its idempotency key. The event relay is down: events wait in the outbox table.
The post commits in Postgres with an event. Workers fan the ID out to Redis timelines. The feed service merges, hydrates and returns 20 posts.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Redis | Sorted sets: ZADD, ZRANGE BYSCORE REV LIMIT (ZREVRANGEBYSCORE before 6.2) | A timeline ordered by time; one page is one range read. | Leaderboards, delay queues |
| Redis | ZREMRANGEBYRANK | Trim to the newest 128 in the same round trip as the add. | Recent-items lists |
| Redis | Listpack encoding up to 128 entries | 31.4 bytes per entry, against 101.9 as a skip list. | Small hashes and lists |
| Redis | Pipelining | Fan-out sends 500 followers' writes per round trip. | Any batch of commands |
| Redis | Cluster: keys spread by hash slot | Timelines spread over nodes by user. No timeline spans two nodes. | Sessions, caches |
| Redis | Scores are float64 | Limit Exact only to 2^53, so the score is the millisecond, not the ID. | |
| Postgres | Composite index (followee_id, follower_id) | All followers of an author in one range, for fan-out. | Any reverse lookup on a link table |
| Postgres | bigint primary key from a time-ordered ID | Inserts land at the right edge of the index; ID order is time order. | Orders, events, messages |
| Postgres | Streaming replicas | Fan-out and hydrate read from replicas, not the primary. | Read scaling |
| Postgres | Transactions with an outbox table | The post and its event commit together, so no post misses its fan-out. | Any change that must emit an event |
| Kafka | Partitioned log keyed by author | Fan-out jobs in order per author; workers scale with partitions. | Notifications, search indexing |
| CDN | Edge caches in front of object storage | Media never passes through the API. | Avatars, static files |
Redis sorted sets give me a capped, time-ordered list per user with range reads. Postgres gives me the truth and the reverse follow index.
| strategy | post costs | feed load costs | status |
|---|---|---|---|
| Pull: merge every followed account at read time | 1 write | 33.1 reads on average, p99 206 | Few follows, low rate |
| Push to every follower | Up to 41,326 writes for one post in the simulation | 1 read | No large accounts |
| Push to active followers | Mean 13.2, against 33.0 | 1 read; a rebuild on return | Approved |
| Hybrid: push below a threshold, pull above it | Worst 3,846 at a 10,000-follower threshold | 2.9 reads on average | Approved |
| Push post bodies, not IDs | Every edit rewrites every copy | 1 read | Not approved |
- The threshold is a number in config. Raise it if reads cost too much; lower it if fan-out lags.
- An account that crosses the threshold changes mode for new posts only. Old entries stay until trimmed.
post(author, body):
id = next time-ordered ID1 // 41 bits ms, node, sequence
insert the post into Postgres // the truth
add id to the author's own list2 // ZADD au:{author}
IF followers(author) >= THRESHOLD: RETURN3 // large account: readers pull it
FOR EACH batch of 500 active followers4 f: // one round trip per batch
ZADD tl:{f} score=ms(id) member=id
ZREMRANGEBYRANK5 tl:{f} 0 -(CAP+1) // keep the newest CAP- 1A Snowflake-style ID: the millisecond is the high bits, so IDs sort by time (X4).
- 2Every author keeps a list. Readers of a large account read it; a rebuild reads it too.
- 3The hybrid in one line. A large account costs nothing at post time.
- 4Pipelined. One worker wrote 106,000 entries a second to one Redis in the lab.
- 5Trim in the same round trip. The timeline never grows past the cap.
Tested source Go: post, deliver, fan out
// Post stores the post, adds it to the author's own list, and pushes it to the timelines of
// active followers, unless the author has Threshold followers or more. It returns the post ID and
// the number of timelines written.
func (s *Service) Post(ctx context.Context, author int64, body string) (int64, int, error) {
id, err := s.IDs.Next()
if err != nil {
return 0, 0, fmt.Errorf("post id: %w", err)
}
var followers int64
if err := s.DB.QueryRow(ctx, Q("insert_post"), id, author, body).Scan(&followers); err != nil {
return 0, 0, fmt.Errorf("insert post: %w", err)
}
n, err := s.Deliver(ctx, author, id, followers)
return id, n, err
}
// Deliver puts a stored post into the author's own list, then into followers' timelines unless
// the author is pulled.
func (s *Service) Deliver(ctx context.Context, author, id, followers int64) (int, error) {
if err := s.add(ctx, s.authored(author), id); err != nil {
return 0, err
}
if followers >= s.Threshold {
return 0, nil // a large account: readers pull its posts
}
return s.FanOut(ctx, author, id)
}
// FanOut writes one entry into the timeline of every active follower, in pipelined batches. Each
// timeline is trimmed to Cap entries in the same round trip.
func (s *Service) FanOut(ctx context.Context, author, id int64) (int, error) {
targets, err := s.followers(ctx, author, s.ActiveSince)
if err != nil {
return 0, err
}
for chunk := range slices.Chunk(targets, max(s.Batch, 1)) {
_, err := s.RDB.Pipelined(ctx, func(p redis.Pipeliner) error {
for _, f := range chunk {
key := s.timeline(f)
p.ZAdd(ctx, key, redis.Z{Score: score(id), Member: member(id)})
p.ZRemRangeByRank(ctx, key, 0, int64(-s.Cap-1))
}
return nil
})
if err != nil {
return 0, fmt.Errorf("fan out post %d: %w", id, err)
}
}
return len(targets), nil
}
I push most authors and pull accounts with very many followers. In the simulation, pulling 10 accounts cut the worst post from 16,499 writes to 3,846, and reads rose from 1 to 2.9 per load.
| 100,000 users, one post each | this setting | all push | all pull |
|---|---|---|---|
| Accounts pulled at read time | 10 | 0 | 100,000 |
| Timeline writes per post: mean | 12.4 | 13.2 | 0.0 |
| Timeline writes for the worst post | 3,846 | 16,499 | 0 |
| Reads per feed load: mean / p99 | 2.9 / 9 | 1.0 / 1 | 33.1 / 206 |
| Timeline cache | 36.2 MB | 38.3 MB | 0.0 MB |
The worst post writes 3,846 timeline entries: about 36 ms at the measured 106,201 entries a second. Each feed load merges 2.9 sources on average.
simulate(graph, THRESHOLD, active_only):
FOR EACH author: // every user posts once1
IF followers(author) < THRESHOLD:
writes += followers (active ones only, if asked2)
FOR EACH active reader: // every active user loads once
reads = 1 + followed accounts at or above THRESHOLD
memory = SUM over timelines of min(received, CAP)3- 1A power-law graph: popularity falls as 1 / rank^0.8. Median 14 followers, top account 41,326.
- 240% of users count as active. Inactive users get no timeline and no writes.
- 3Memory counts entries kept after the cap of 128. Bytes per entry come from Redis.
Tested source Go: cost of one threshold
// Simulate computes the cost of one threshold. With activeOnly, fan-out skips inactive followers,
// and inactive users keep no timeline; they get one rebuilt by a pull when they return.
func (g *Graph) Simulate(threshold int, activeOnly bool, capEntries int) Cost {
c := Cost{Threshold: threshold, ActiveOnly: activeOnly}
pulled := make([]bool, g.Users)
for u := range g.Users {
pulled[u] = len(g.Followers[u]) >= threshold
if pulled[u] {
c.Pulled++
}
}
keeps := func(u int32) bool { return !activeOnly || g.Active[u] }
writes := make([]int, 0, g.Users) // one post per user
received := make([]int, g.Users) // timeline entries per reader
for author := range g.Users {
w := 0
if !pulled[author] {
for _, f := range g.Followers[author] {
if keeps(f) {
w++
received[f]++
}
}
}
writes = append(writes, w)
}
var reads []int
for u := range g.Users {
if !g.Active[u] {
continue // only active users load the feed
}
r := 0
if threshold > 0 {
r = 1 // the timeline itself
}
for _, v := range g.Following[u] {
if pulled[v] {
r++ // the newest posts of one pulled author
}
}
reads = append(reads, r)
}
for u := range g.Users {
if threshold > 0 && keeps(int32(u)) {
c.Timelines++
c.Entries += int64(min(received[u], capEntries))
}
}
c.WritesMean, c.WritesP99, c.WritesMax = stats(writes)
c.ReadsMean, c.ReadsP99, c.ReadsMax = stats(reads)
return c
}
Moving the threshold trades the worst post against the read cost. One threshold near the top accounts removes most of the write spike for a few extra reads.
page(reader, cursor, n):
sources = tl:{reader}
+ au:{a} FOR EACH large account a the reader follows1
IN ONE ROUND TRIP, FOR EACH source:
ZRANGE source ms(cursor) -inf BYSCORE REV LIMIT 0 n2
keep ids < cursor3 // ties in one ms
merge all, newest first, drop repeats4
RETURN the first n // next cursor = last id5- 1The pull half of the hybrid: a few extra sorted sets, read in the same round trip.
- 2Newest first, starting at the cursor's millisecond. Inclusive, because other posts can share that millisecond.
- 3Members with equal scores sort by member. IDs are zero-padded, so member order is ID order.
- 4A post can sit in two sources while an account crosses the threshold.
- 5The ID carries its own time, so the cursor needs nothing else.
Tested source Go: page, merge, cursor
// Page returns up to n post IDs older than the cursor, newest first. The cursor is the last ID of
// the previous page (0 for the first page). Sources are the reader's timeline plus the own list of
// every pulled account the reader follows; one pipelined round trip reads them all.
func (s *Service) Page(ctx context.Context, reader, cursor int64, n int) ([]Entry, error) {
pulled, err := s.pulled(ctx, reader)
if err != nil {
return nil, err
}
keys := []string{s.timeline(reader)}
for _, a := range pulled {
keys = append(keys, s.authored(a))
}
upTo := upTo(cursor)
cmds := make([]*redis.StringSliceCmd, len(keys))
_, err = s.RDB.Pipelined(ctx, func(p redis.Pipeliner) error {
for i, k := range keys {
cmds[i] = p.ZRangeArgs(ctx, newest(k, upTo, 0, n))
}
return nil
})
if err != nil {
return nil, fmt.Errorf("read feed of %d: %w", reader, err)
}
var merged []Entry
for i, c := range cmds {
ids, err := below(c.Val(), cursor, n)
if err != nil {
return nil, err
}
if len(ids) < n && len(c.Val()) == n { // IDs in the cursor's millisecond used up the read
if ids, err = s.older(ctx, keys[i], cursor, n); err != nil {
return nil, err
}
}
merged = append(merged, ids...)
}
slices.SortFunc(merged, func(a, b Entry) int { return cmp.Compare(b, a) })
merged = slices.Compact(merged)
return merged[:min(n, len(merged))], nil
}
// newest is ZRANGE key max -inf BYSCORE REV LIMIT offset n: the newest entries at or below max.
// Before Redis 6.2 the same command was ZREVRANGEBYSCORE.
func newest(key, maxScore string, offset int64, n int) redis.ZRangeArgs {
return redis.ZRangeArgs{Key: key, Start: maxScore, Stop: "-inf", ByScore: true, Rev: true, Offset: offset, Count: int64(n)}
}
// upTo is the highest score a page may read: the cursor's millisecond, inclusive, because other
// posts of that millisecond may sort below the cursor.
func upTo(cursor int64) string {
if cursor == 0 {
return "+inf"
}
return strconv.FormatFloat(score(cursor), 'f', 0, 64)
}
// below keeps the members whose ID is lower than the cursor, up to n.
func below(members []string, cursor int64, n int) ([]Entry, error) {
var out []Entry
for _, m := range members {
id, err := strconv.ParseInt(m, 10, 64)
if err != nil {
return nil, fmt.Errorf("timeline member %q: %w", m, err)
}
if (cursor == 0 || id < cursor) && len(out) < n {
out = append(out, id)
}
}
return out, nil
}
// older reads one sorted set page by page from the cursor's millisecond until it has n IDs below
// the cursor. Page needs it only when one millisecond holds n posts or more.
func (s *Service) older(ctx context.Context, key string, cursor int64, n int) ([]Entry, error) {
var out []Entry
for offset := int64(0); len(out) < n; {
got, err := s.RDB.ZRangeArgs(ctx, newest(key, upTo(cursor), offset, n)).Result()
if err != nil {
return nil, fmt.Errorf("read %s: %w", key, err)
}
ids, err := below(got, cursor, n-len(out))
if err != nil {
return nil, err
}
out = append(out, ids...)
if len(got) < n {
break
}
offset += int64(len(got))
}
return out, nil
}
| choice | status | why |
|---|---|---|
| Sorted set, score = ms, member = ID | Approved | Range by time, exact scores, ties broken by ID. |
| Score = 64-bit ID | Not approved | A double holds 53 bits. Low bits are lost. |
| List with LPUSH and LTRIM | Strict append order | A late or out-of-order post cannot take its place by time. |
| Offset pages (ZRANGE by rank) | Not approved | 4 repeats after 4 new posts, in the lab. |
| Cursor pages (last ID seen) | Approved | 0 repeats, no gap. |
| Cap at 128 | Approved | Listpack: 31.4 B per entry. |
| Cap at 800 | Deep scrolling is common | Skip list: 101.9 B per entry. |
Recorded: 4 posts arrive between page 1 and page 2, page size 10. The lab also checks that a hybrid feed returns exactly the pages of a pure push feed, at page sizes 1, 3 and 7.
The score is the post's millisecond and the member is the post ID. A page reads backwards from the cursor's millisecond and skips IDs at or above the cursor.
| change | at once | later |
|---|---|---|
| Post deleted | deleted_at set; hydrate skips it | ZREM from every follower's timeline |
| Unfollow | Filter that author out of the page | Remove their entries from the timeline |
| Block | Filter at read time, both ways | Remove entries |
| Account goes private | Filter for non-followers | Nothing: timelines hold followers only |
| User inactive 30 days | Fan-out skips them | Rebuild the timeline on return |
Anything that hides a post is a filter at read time first. Cleaning the timelines is a background job that can lag safely.
| method | status |
|---|---|
| Newest first from the timeline | The baseline |
| Candidates from the timeline, then a scoring model, top 20 | Approved |
| Snapshot the ranked IDs per session; the cursor is a position | Approved |
| Rank on every page request | Not approved |
| Score inside the fan-out | Features known at post time |
- Ranking every page again reorders posts the reader already saw. A snapshot keeps pages stable.
- Scoring needs features: likes, replies, how close the reader is to the author. A stream job keeps them current (B7).
I build the chronological feed first. Ranking reuses it as the candidate source and adds a scoring step.
| event | result | why it is safe | saved by |
|---|---|---|---|
| A fan-out worker crashes mid-post | Some followers have the entry, some do not. | The event is not acked, so another worker runs it again. A second ZADD of the same member changes nothing. | Idempotent ZADD |
| The post commits, the event relay is down | Fan-out is late. | The event waits in the outbox table and goes out when the relay returns. | Outbox |
| A Redis node fails over and loses recent writes | Some timelines miss recent IDs. | Timelines are a cache. A rebuild from Postgres restores them; posts are never lost. | Rebuild |
| A timeline key is evicted | The read finds no key. | Rebuild on the read path, then serve. | Rebuild |
| An account with 100 M followers posts | Push would queue 100 M writes. | The account is above the threshold: no writes; readers pull its list. | Hybrid |
| The fan-out queue lags by minutes | Followers see posts late. | Eventual consistency is in the requirements. Alert on lag in seconds; add workers up to the partition count. | Lag alert |
| A deleted post is still in timelines | The ID is read. | Hydrate skips deleted rows, so it is never shown. | deleted_at |
| A post arrives late, out of order | Its ID is older than newer posts. | The score places it by time, not by arrival. | Sorted set |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One Postgres, feed by a join over follows and posts (D3). | About 200 feed reads a second at 1,000 follows, in the lab. | Feed latency grows with the number of follows. |
| 2 | A timeline table, written by fan-out on write. | About 30,000 reads a second, flat as follows grow. | Fan-out writes take most of the primary's write budget. |
| 3 | Redis timelines, workers fed by an event log, hydrate from replicas. | About 14,000 feed pages and 191,000 fan-out entries a second on one Redis. | One post takes minutes to fan out; memory grows with inactive users. |
| 4 | Hybrid and active-only fan-out. | The worst post drops to the threshold; writes and memory fall by the inactive share. | Memory or write rate passes one node; one region adds latency far away. |
| 5 | Redis Cluster by user, Postgres shards by author, regional read copies (X2). | Add nodes as users grow. This prompt needs about 2.0 TB of timelines and 93,000 writes a second. | Top of the ladder. |
Demand from panel C. Capacity measured in the lab with 8 clients on one shared laptop, median of 3 runs. Repeat runs varied by up to 2×, so read these as orders of magnitude. The feed page includes a Postgres lookup of followed large accounts.
I start with one Postgres and a join, add a timeline table, then move timelines to Redis. I pull large accounts and shard last.
| question | answer | deeper |
|---|---|---|
| How do you make IDs that sort by time on many machines? | Use a Snowflake-style ID: milliseconds, a node number and a sequence. Each node makes IDs alone, and a clock step backwards must make the node wait. | X4 |
| How do you shard timelines and posts? | Timelines by user in Redis Cluster; posts by author_id with the ID as the order key. A feed read touches one timeline shard plus the hydrate fan-in. | X2 |
| A post goes viral: millions read it a second. | Cache the hydrated post in memory near the service and on the CDN for media. Keep like counts as sharded counters merged every second. | B4 |
| The author must see their own post at once. How? | Merge the author's own list into their feed at read time. That gives read-your-writes without waiting for the fan-out. | X3 |
| The fan-out queue lags. What do you check? | Lag in seconds per partition, the slowest worker, and one hot author on one partition. Add workers up to the partition count. | B6 |
| Users want to search posts. | Index posts from the same event log into a search index. The feed and search read the same events at their own pace. | B8 |
| How do images load fast worldwide? | Upload once to object storage with a presigned URL; serve resized copies from CDN edges with long cache lifetimes. | B5 |
0 of 9 known
An account with 100 million followers posts. What does pure push cost, and what does the hybrid do instead?
Why does the timeline hold post IDs and not post bodies?
Why is the sorted-set score the millisecond and not the 64-bit post ID?
A reader opens page 2 after 4 new posts arrived. What does an offset page show?
Why cap each timeline at 128 entries?
A user returns after three months away. Fan-out skipped them. What do they see?
A post is deleted. How fast does it leave every feed?
Where is the feed eventually consistent, and why is that acceptable?
How do you page through a ranked feed?
- demand
- About 58,000 feed loads and 1,200 posts a second.
- push all
- 20 B timeline writes a day, about 231,000 a second.
- worst post
- 16,499 writes with push; 3,846 with a 10k threshold (simulated).
- fan-out
- About 106,000 entries a second per worker; 100 k followers in 0.9 s.
- memory
- 31.4 B per entry up to 128; 101.9 B as a skip list.
- reads
- About 14,000 pages a second from one timeline; 2,500 merging 50 lists.
- scores
- A float64 is exact to 2^53; a 64-bit ID is not.
Postgres 16 and Redis 8 on an 8-core laptop shared with other runs, 8 clients. Simulation: 100,000 users, seed 5.