Filter concepts by levelShowing all levels.

System Design · Section 92

Feed and Timeline Storage

Level
advanced
Read
14 min
Concepts
1

A materialised timeline is a per-user ordered list of post references, and every storage question about it follows from its being derived data rather than a record. The first decision is how much of each post to copy in. A reference-only entry — post id, sort score, author id — keeps the timeline small and always correct, at the cost of resolving the post and its author on read. A denormalised snapshot makes the read one operation and creates a consistency obligation proportional to audience size: a display-name change on an account with millions of followers becomes hundreds of millions of updates, which in practice means the update is never run and the stale name ships forever. So the rule is to denormalise only what is immutable for the life of the post and to resolve everything mutable, which keeps invalidation work proportional to what actually changes. The second decision is that timelines are bounded. Nobody scrolls to the beginning, so a timeline is trimmed to a fixed length and deeper history falls back to a query over posts and follows — the same path that rebuilds a timeline after a fan-out failure. That converts storage from followers times posts times forever into a constant per user. Hot users are isolated on both sides: a hot author is kept out of fan-out entirely, and a hot timeline is served from replicas, because hash placement distributes keys evenly but not load. And invalidation is four distinct events with different costs — an edit leaves membership unchanged, a delete leaves dangling entries that are swept asynchronously behind a read-time filter, an unfollow removes one author's entries (which is why the author id is stored), and a block does both directions and suppresses future fan-out. Every one of them scales with audience size, so every one of them runs asynchronously, for the same reason fan-out does.

System Design overview

What is true here

  1. A timeline entry holds a post id, a sort score and an author id — denormalise only fields that are immutable for the life of the post.
  2. Denormalising a mutable field makes a profile edit an update of hundreds of millions of rows, which means it never runs and the data stays stale.
  3. Bound the timeline and fall back to a query for deep history; an unbounded materialised list is a second copy of the database.
  4. Hash placement spreads keys evenly, not load — a hot timeline needs replicas, and a hot author needs to be excluded from fan-out.
  5. Edit, delete, unfollow and block invalidate differently; the delete path needs a read-time filter because a sweep can never close the window.

What you will be able to do

  • Decide field by field what belongs in a materialised timeline entry and justify each choice by mutability
  • Bound timeline storage and design the fallback path that serves deep history
  • Isolate a hot author and a hot reader with the right mechanism for each side
  • Enumerate the four invalidation events and specify the work and the read-time filtering each requires

The storage layer

What a timeline entry holds, why it is bounded, how hot users are isolated, and what invalidation costs.

Materialised timelines, denormalization, trimming and invalidation

coreadvanced

A materialised timeline is a per-user ordered list of post references, and the first storage decision is how much of each post to copy into it. Storing only the post id keeps the timeline tiny and correct — an edited post is edited once, everywhere — at the cost of a second lookup per item on read. Storing a denormalised snapshot (author name, avatar, first line of text) makes the read a single operation and creates a consistency problem: every copy has to be updated when the source changes, and a display-name change would otherwise touch millions of rows. The usual answer is to denormalise only fields that are immutable for the life of the post and to look up everything mutable, which keeps invalidation work proportional to what actually changes. The second decision is that a timeline is bounded: nobody scrolls to the beginning of time, so timelines are trimmed to a few hundred or thousand entries and deeper history is served by falling back to a query over the source. That single choice turns storage from unbounded — followers times posts, forever — into a fixed cost per user. Hot users need isolation on both sides: a hot author is handled by not fanning them out at all, and a hot timeline is handled by keeping it in a cache with its own replica so one popular account's reads cannot saturate the shard it happens to live on. Finally, invalidation covers four events with different costs: a post edit invalidates cached renderings but not timeline membership, a post delete requires removal or filtering at read time, an unfollow requires removing that author's entries from a timeline, and a block requires both directions — and because every one of these is proportional to audience size, they run asynchronously, exactly like fan-out.

Think of it as

A timeline is a printed contact sheet, not a filing cabinet. It holds small references in an order, it is deliberately short, and it is regenerable from the negatives — which are the posts and follows tables. Once you accept it is a printout rather than the record, the awkward questions get simple answers: an edit changes the negative, so the printout can carry only what never changes; the printout has a fixed number of frames, so old ones fall off the end; and if the printout is lost or wrong you print another one.

text
# a timeline as a bounded, scored list
ZADD   timeline:{user_id} {created_ms} {post_id}
ZREMRANGEBYRANK timeline:{user_id} 0 -1001   # keep
                                             # newest
                                             # 1000
# page: a range read from a cursor, not an offset
ZREVRANGEBYSCORE timeline:{user_id}
                 ({cursor_ms} -inf LIMIT 0 20

# unfollow: entries carry the author id, so they
# can be removed without reading every post
ZREM   timeline:{user_id} {post_ids_by_author}

What we're doing: Follow a display-name change and a post deletion through two designs, one over-denormalised and one not.

invalidation-cost.txttext
A user with 2.1M followers changes their
display name.

OVER-DENORMALISED timeline entries
  {post_id, text, author_name, author_avatar,
   created_at}
  The name appears in every entry this author
  has ever produced: 340 posts x 2.1M followers
  = ~714,000,000 rows to update, for a name
  change. In practice this is never done, so
  the old name is displayed indefinitely and
  the bug is closed as "won't fix".

REFERENCE-ONLY entries
  {post_id, author_id, created_at}
  The name lives in one row in the users table.
  Changing it changes it everywhere, instantly,
  at the cost of resolving author records on
  read -- which is a small, cacheable lookup
  shared across every entry by that author.

Now the author deletes one post.

  Both designs have ~2.1M dangling entries.
  Removal is asynchronous, so the read path
  must filter ids that no longer resolve --
  which it already does, because a post can be
  deleted between a timeline read and a post
  fetch no matter how fast the sweep is.
8
Seven hundred million writes for a display-name change is the whole argument against denormalising mutable fields. The failure is not that it is slow — it is that nobody will run it, so the system quietly ships stale data forever.
18
The lookup that replaces it is not free, but its cost is shared: one author record resolves for every entry by that author on the page, and it caches extremely well because author records change rarely.
27
Read-time filtering of unresolvable ids is required regardless of how good the sweep is, because there is always a window between reading the timeline and fetching the posts. Once that filter exists, the asynchronous sweep is an optimisation rather than a correctness requirement.

Why this works: The two events split cleanly: a delete changes membership and must be swept, an edit does not and should never have to be. Keeping mutable fields out of timeline entries is what collapses the second case to nothing, and it is why "denormalise only what is immutable" is the rule rather than "denormalise for speed".

Letting materialised timelines grow without bound

Wrong

text
ZADD timeline:{user} {ts} {post_id}
# and nothing else. Every post from every
# followed account, forever.
# 10M users x 5,000 entries each and growing =
# storage that scales with total history rather
# than with useful data.

Better

text
ZADD timeline:{user} {ts} {post_id}
ZREMRANGEBYRANK timeline:{user} 0 -1001
# fixed 1,000 entries per user. Anyone scrolling
# past that -- a small fraction of sessions --
# falls back to a query over posts + follows.

What you see: Timeline storage grows faster than the user base and eventually dominates the storage bill, while the tail of that data is read almost never — the overwhelming majority of sessions read the first two pages.

Why: A materialised timeline is a cache of the most recent slice, and a cache without an eviction policy is just a second copy of the database. Bounding it turns per-user storage into a constant and moves the rare deep-scroll case onto a query path that already exists, because that path is also what rebuilds a timeline after a fan-out failure.

Three stores with three different jobs

System of record

posts

full content, mutable

follows

the graph

blocks

suppression rules

Materialised timelines

post id + score + author id

immutable fields only

trimmed to ~1,000 entries

deeper history falls back to a query

Read caches

rendered pages

invalidated on edit

hot timelines

replicated so one reader cannot saturate a shard

  • System of record — authoritative, never derived
    • posts — full content, mutable
    • follows — the graph
    • blocks — suppression rules
  • Materialised timelines — derived, bounded, rebuildable
    • post id + score + author id — immutable fields only
    • trimmed to ~1,000 entries — deeper history falls back to a query
  • Read caches — shortest-lived, cheapest to lose
    • rendered pages — invalidated on edit
    • hot timelines — replicated so one reader cannot saturate a shard

What to store in a timeline entry

What to store in a timeline entry
FieldStore it?Why
Post idAlwaysThe reference; everything else can be resolved from it
Created-at / sort scoreAlwaysOrdering and cursor pagination depend on it
Author idYesNeeded to filter on unfollow and block without a lookup
Post text snippetOnly if posts are immutableAn editable post makes every copy a stale copy
Author display name / avatarNoMutable, and shared across a huge number of entries
Like / reply countsNoChange constantly and independently of the post

Four invalidation events and what each costs

Four invalidation events and what each costs
EventTimeline membershipWork required
Post editedUnchangedInvalidate cached renderings only — cheap if nothing mutable was denormalised
Post deletedEntries are now danglingRemove asynchronously, and filter deleted ids at read time until the sweep completes
UnfollowThat author's entries must goRemove by author id from one timeline — cheap, and why author id is stored
BlockBoth directionsRemove from both timelines and suppress future fan-out between the pair

Remember: A materialised timeline is a bounded, derived list of references — post id, sort score, author id — and nothing mutable, because a denormalised display name turns a profile edit into hundreds of millions of writes nobody will ever run. Trim it to a fixed length and serve deep history from a query, so per-user storage is a constant. Isolate hot users on both sides: keep hot authors out of fan-out, and serve hot timelines from replicas. And treat invalidation as four distinct events — edit, delete, unfollow, block — each proportional to audience size, so each runs asynchronously with a read-time filter covering the window.

See also: fan out on write vs fan out on read · caching ranking pagination and materialization · stampede hot keys and memory pressure · ttl eviction and invalidation · hot warm cold and retention policies · choosing shard keys

Advertisement