Skip to content
Go back

Designing a Ranked News Feed with Hybrid Fan-out

Published:  at  10:00 AM

1. Problem and requirements

We need a home feed for a social product. A user follows authors, opens the home screen, and receives a ranked stream of their posts. A post should normally become visible within 30 seconds. The first page should load with p95 latency below 300 ms. Refreshing or paginating must not intentionally duplicate items or lose items merely because ranking changed between requests.

The users of this system are readers, authors, and internal ranking and abuse-prevention services. The functional requirements are:

[SOURCE FACT] The workload is read-heavy: approximately 1,000 feed reads per post write. Freshness and read latency both matter, but a few seconds of staleness is acceptable. Ranking is personalized and can change as features arrive.

[ANALYSIS] A globally identical order is therefore not a useful promise. The useful guarantees are a stable page boundary, no intentional duplicates, and monotonic visibility of committed content within the reader’s selected snapshot.

The central design choice is where aggregation occurs. Fan-out-on-write copies a post into each follower’s inbox, making reads cheaper but potentially creating millions of writes for a high-degree author. Fan-out-on-read avoids that write amplification, but the reader pays the merge cost on every request. The proposed design uses both paths.

2. Scale estimation

The following are explicit assumptions, not forecasts. They make the capacity calculation easy to replace with product measurements.

QuantityAssumptionCalculation / result
Daily active users50 millionProduct target
Feed requests per active user/day2050M x 20 = 1 billion requests/day
Average feed RPS1B / 86,40011,574 RPS
Peak factor10x average115,740 RPS; provision for 150k RPS
New posts/day1 millionOne post per 50 DAU per day is a conservative write baseline for a read-heavy feed
Post write RPS1M / 86,40011.6 average, about 116 peak
Read:write ratio1B / 1M1,000 feed reads per post
Retained post record2 KiB1M x 2 KiB x 365 = 0.73 TB/year before replicas and indexes
Feed-edge record32 bytesThe illustrative fan-out subset averages 5 followers/post: 5M edges/day x 32 bytes x 30 days = 4.8 GB logical hot-inbox data
Feed response20 items x 1.5 KiB30 KiB; 115,740 x 30 KiB x 8 = 27.8 Gbit/s peak egress before compression
Availability target99.95% monthlyAbout 22 minutes of unavailability/month for feed reads; writes may degrade to delayed fan-out

The average of 5 followers is a [SOURCE FACT / ASSUMPTION] for the subset selected for fan-out, not a universal social-graph assumption. High-degree authors use the read path. At 10x growth, the stated assumption yields 500M posts/day and average feed traffic of 115,740 RPS. The hybrid boundary should then move according to measured fan-out amplification, rather than a fixed follower-count threshold.

In practice, storage is larger. Three replicas, indexes, tombstones, moderation metadata, and media pointers can turn the 0.73 TB/year post estimate into approximately 3 TB/year. Media belongs in object storage and is excluded from the feed-database calculation. Inbox edges have a short TTL because they accelerate retrieval; they are not the source of truth.

3. API design

All endpoints use HTTPS and an authenticated user identity from the access token. X-Request-ID is accepted or generated and propagated to logs and traces. Cursors are opaque and signed. They contain a feed version and the last (rank_key, post_id) boundary.

Read the feed

GET /v1/feed?limit=20&cursor=eyJ2Ijox...
Authorization: Bearer <token>
X-Request-ID: 7f2c...
{
  "items": [{"post_id":"p91", "author_id":"u7", "text":"...", "created_at":"2026-08-15T02:00:00Z", "rank":0.984}],
  "next_cursor":"eyJ2IjoxLCJsYXN0IjpbMC45ODQsInA5MSJdfQ",
  "feed_version":"v-20260815-020001",
  "generated_at":"2026-08-15T02:00:03Z"
}

limit is capped at 100. The cursor keeps the same ranking snapshot for a bounded session, so a new post can appear at the top without shifting the current page. If the snapshot expires, the service returns a new cursor and a snapshot_expired warning instead of silently repeating rows.

Create a post

POST /v1/posts
Authorization: Bearer <token>
Idempotency-Key: 01J...
Content-Type: application/json

{"text":"hello", "media_ids":["m1"]}

The response is 201 Created with the durable post_id. The idempotency key is unique per author for 24 hours and stores the original response. A timeout after the database commit can therefore be retried without creating a second post.

Follow and unfollow

PUT    /v1/users/{author_id}/follow
DELETE /v1/users/{author_id}/follow

Both operations are idempotent. The follow record stores following_since; the feed reader uses it as a lower bound so old posts are not unexpectedly backfilled. DELETE also emits an invalidation event. Materialized rows are filtered at read time until asynchronous cleanup completes.

Real-time insertion

The client opens GET /v1/feed/stream over a WebSocket or server-sent-event connection. The server sends {event:"new_item", post_id, feed_version} only after authorization and a lightweight eligibility check. The client prepends the item outside the paginated list and deduplicates by post_id; the next HTTP page remains cursor-based.

4. Data model

[PROPOSED DESIGN] Use sharded SQL as the source of truth. Post creation, follow state, moderation state, and idempotency all need constraints and transactions. The denormalized inbox is a retrieval accelerator and can be rebuilt or allowed to expire.

CREATE TABLE posts (
  post_id        BIGINT PRIMARY KEY,
  author_id      BIGINT NOT NULL,
  created_at     TIMESTAMPTZ NOT NULL,
  text           TEXT NOT NULL,
  rank_features  JSONB NOT NULL,
  visibility     SMALLINT NOT NULL DEFAULT 1,
  deleted_at     TIMESTAMPTZ NULL
);
CREATE INDEX posts_author_time ON posts (author_id, created_at DESC, post_id DESC);

CREATE TABLE follows (
  follower_id       BIGINT NOT NULL,
  followed_id       BIGINT NOT NULL,
  following_since   TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (follower_id, followed_id)
);
CREATE INDEX follows_reverse ON follows (followed_id, follower_id);

CREATE TABLE feed_inbox (
  reader_id      BIGINT NOT NULL,
  rank_key       DOUBLE PRECISION NOT NULL,
  post_id        BIGINT NOT NULL,
  author_id      BIGINT NOT NULL,
  inserted_at    TIMESTAMPTZ NOT NULL,
  expires_at     TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (reader_id, rank_key, post_id)
);
CREATE INDEX feed_inbox_expiry ON feed_inbox (expires_at);

CREATE TABLE outbox_events (
  event_id       BIGSERIAL PRIMARY KEY,
  aggregate_id   BIGINT NOT NULL,
  event_type     TEXT NOT NULL,
  payload        JSONB NOT NULL,
  created_at     TIMESTAMPTZ NOT NULL,
  published_at   TIMESTAMPTZ NULL
);

The post, follow, and outbox rows should be committed atomically where they share a database shard. A publisher reads unpublished outbox rows and sends them to the fan-out and ranking pipelines. Consumers must be idempotent: retries can deliver the same event more than once, so inbox insertion uses a uniqueness key and safe upsert semantics.

5. Hybrid fan-out

[PROPOSED DESIGN] The publish path writes the post and its outbox event first. It does not wait for every follower inbox write before returning success. A fan-out consumer reads the event, checks the author’s degree and policy state, and chooses a path:

The read service obtains a candidate set from the reader’s inbox, then queries recent posts from high-degree followed authors. It merges candidates by rank_key and post_id, filters follow state and visibility, and fetches post bodies in batches. A cache may hold post bodies or ranking features, but it must not become the only copy of committed data.

This split makes the trade-off explicit. Fan-out-on-write spends storage and asynchronous write capacity to reduce read work. Fan-out-on-read spends read CPU and backend queries to avoid write amplification. The boundary is an operational policy derived from measured cost and latency, not a universal constant.

6. Ranking and pagination

Ranking can change when features arrive, content is deleted, or a user follows or unfollows an author. Re-ranking an already open page would make ordinary offset pagination unsafe.

[PROPOSED DESIGN] At the first request, create a feed version that identifies the ranking snapshot and its expiry. Store the last (rank_key, post_id) pair in the signed cursor. The next request asks for items strictly after that pair in the same snapshot. post_id is the deterministic tie-breaker when two items have the same rank.

New real-time items are separate from the cursor sequence. The client can show them above the current list, while the paginated sequence continues from its original boundary. The client deduplicates by post_id because delivery and HTTP retrieval can overlap.

If an item is deleted or blocked after a cursor was issued, the read service filters it and continues scanning. If the snapshot itself has expired, returning snapshot_expired is safer than pretending that the old boundary still has the same meaning.

7. Freshness, moderation, and failures

The 30-second visibility target is a normal operating target, not a transaction guarantee. Outbox lag, consumer retries, backpressure (giới hạn tốc độ để bảo vệ downstream), and ranking-service latency can delay materialization. The read path can still discover recent posts from followed authors when appropriate, so delayed fan-out does not make a committed post permanently unavailable.

Deletion and blocking are correctness paths, not just cleanup jobs. The visibility check runs while reading, and invalidation events remove or expire materialized rows asynchronously. This closes the window in which an inbox row refers to content that is no longer eligible.

[PROPOSED DESIGN] Each downstream call should have a timeout (giới hạn thời gian chờ), bounded retry with jitter, and a fallback appropriate to the dependency. A ranking timeout can use a previously computed rank or a recency-ordered candidate set; a post-body cache miss should fail the affected item rather than fail the whole feed. A circuit breaker prevents a failing dependency from consuming all feed worker capacity. Queue depth and outbox age expose freshness degradation before users report it.

Feed reads can remain available when fan-out is delayed. Post creation should return failure if the source-of-truth transaction fails, but it can return success before asynchronous fan-out finishes. The idempotency record protects retries around that boundary.

8. Guarantees and trade-offs

The design provides these practical guarantees:

It does not promise a globally stable ranking, zero staleness, or zero duplicates across a real-time stream and an HTTP page. Those would be different, more expensive guarantees. The hybrid approach is useful because it places each cost where this workload can afford it: asynchronous writes for ordinary fan-out, bounded read-time merging for high-degree authors, and explicit snapshot semantics for pagination.


Share this post on:

Previous Post
Designing a Durable Multi-Channel Notification System
Next Post
Distributed Rate Limiter: Design and Failure Behavior