8 min read

New problem: a feed isn't a query, it's a design decision. How you get "the posts from everyone I follow, newest first" onto screen fast is one of the classic forks in system design — fan-out on write vs. fan-out on read — and this app forces you to actually pick one and live with its failure mode.


1. Problem framing

A user follows other users and wants to see their posts, newest first, the instant they open the app — not wait for a slow query across everyone they follow. They also want to know when someone likes or comments on their post, and the system needs to survive one user posting spam as fast as they can click.

2. PRD

Goal: Let users post, follow each other, and see a fast, chronological feed of posts from people they follow, with like/comment counts and notifications.

Non-goals:

  • Ranked/algorithmic feed (chronological only for v1)
  • Private accounts / follow requests
  • Direct messages (that's app 5's problem, not this one's)
  • Optimized support for accounts with very large follower counts — this is an explicit stated scale assumption, not an oversight (see ADR-1 and the retro)

User stories:

  • As a user, I want to follow/unfollow other users.
  • As a user, I want to post, and have it appear in my followers' feeds quickly.
  • As a user, I want to like and comment on posts.
  • As a user, I want to see my feed load near-instantly, with infinite scroll.
  • As a user, I want a notification when someone likes/comments on my post or follows me.

Success metrics:

  • Feed load under 150ms for a typical user (server-side)
  • No duplicate or skipped posts while scrolling, even as new posts arrive

MVP cut line: post/follow/like/comment + chronological feed + notification list. Ranked feeds and private accounts are deferred.

3. Wireframes & UX flow

Screen inventory:

ScreenPurpose
Feed (home)Infinite-scroll chronological posts from followed users
ProfileA user's own posts, follow/unfollow button
Post detailFull post + comments
NotificationsUnread/read list, marks read on view

4. HLD

No new infrastructure category here — Redis is already in the stack (app 3 introduced it for caching) and gets a second job here: rate limiting. The new complexity in this app is entirely in the data model and write path, not in new services.

5. Data model / ERD

FEED_ITEM is the key new idea: it's a materialized, precomputed timeline — one row per (follower, post) pair — not a live join. LIKE has a composite unique constraint on (user_id, post_id) so a user can't like the same post twice.

6. API design + flow mapping

MethodPathBodyAuth
POST/api/posts{ body }required
GET/api/feed?cursor=—required
POST/api/follows{ followeeId }required
POST/api/posts/:id/like—required
POST/api/posts/:id/comments{ body }required
GET/api/notifications—required

Flow: create a post — fan-out on write

One INSERT ... SELECT fans the new post out to every follower's feed_item table in a single statement — this is what makes reading the feed later a simple indexed lookup instead of a live cross-user join.

Flow: read the feed — cursor pagination

The cursor is (created_at, id), not a page number — an OFFSET 40 query silently skips or repeats posts the moment a new post is inserted above the current page, because everything shifts. A cursor tied to the last item seen has no such problem.

Flow: like a post + notify

7. LLD

Module breakdown:

code
app/api/posts/route.ts            — create post + fan-out
app/api/feed/route.ts             — cursor-paginated feed read
app/api/posts/[id]/like/route.ts  — like/unlike + counter + notification
lib/ratelimit.ts                  — Redis token bucket, reused pattern from App 3's cache module

Edge cases worth naming explicitly:

  • Duplicate like — unique constraint on (user_id, post_id) makes a double-click idempotent rather than double-counted; the second insert fails cleanly and the handler treats it as a no-op, not an error the user sees.
  • Self-notification — liking or commenting on your own post must not generate a notification to yourself.
  • Denormalized counter drift — like_count is a column, not a live COUNT(*), updated in the same transaction as the like insert so it can't drift from a crash mid-operation — but a long-running system should still have an occasional reconciliation query that recomputes counts from the source rows, because "in the same transaction" doesn't protect against every possible bug that touches this column later.
  • Rate limiting — enforced server-side via a Redis token bucket per user (e.g. 10 posts/hour, 60 likes/minute) — checked before the write, not as a UI-only throttle.
  • Feed pagination across deletes — if a post referenced by a cursor gets deleted, the cursor still works because it's based on (created_at, id) values, not row positions.

8. NFRs

  • Performance: feed read under 150ms server-side — achievable because feed_item is indexed on (owner_id, created_at, id) and requires no cross-user join at read time.
  • Security / authz: a user only ever reads their own feed_item rows and their own notifications; likes/comments are attributed to the authenticated user, never trusted from client input.
  • Observability: track fan-out write duration per post (this is the metric that will flag the celebrity problem in the retro below, long before it becomes an incident) and feed-read p99 latency.

9. ADRs

code
ADR-1: Fan-out on write (materialized FEED_ITEM), not fan-out on read
Context: opening the feed has to be near-instant and happens far more often
         than any single post is created; a live join across "everyone I
         follow" at read time gets slower as follow-counts grow.
Decision: precompute each follower's timeline at post-creation time into
         FEED_ITEM.
Consequence: reads are a simple indexed lookup; writes get more expensive
             per post, proportional to the author's follower count — an
             explicit, stated tradeoff, not an accident (see retro).

ADR-2: Cursor-based pagination, not OFFSET/LIMIT
Context: a live feed has new rows inserted constantly; offset pagination
         shifts under concurrent inserts, causing skipped or duplicated
         posts while scrolling.
Decision: paginate on a (created_at, id) cursor tied to the last item seen.
Consequence: slightly more complex query, but stable under concurrent writes.

ADR-3: Denormalized like_count / comment_count on POST
Context: computing COUNT(*) on every feed render doesn't scale once posts
         have thousands of likes.
Decision: maintain counter columns, updated transactionally alongside the
         like/comment insert.
Consequence: faster reads, at the cost of a counter that could in principle
             drift from the true row count over time and needs occasional
             reconciliation.

ADR-4: Fan-out happens synchronously in the request for MVP
Context: could push fan-out onto a background queue immediately.
Decision: do it inline, in the same request/transaction that creates the
         post — no queue infrastructure yet.
Consequence: simple for v1, but this is explicitly flagged as the first
             thing that breaks at scale (see retro) — we know exactly where
             this decision runs out of runway.

ADR-5: Rate limiting via the Redis instance already introduced in App 3
Context: need to stop spam posting/liking.
Decision: reuse the existing Redis for a token-bucket rate limiter rather
         than adding new infrastructure.
Consequence: one more responsibility on infra we already operate, instead
             of a new moving part.

10. Deploy plan

  • Environments: local, Preview, Production — same shape as prior apps.
  • CI: typecheck, lint, plus a test that inserts a post for a user with N followers and asserts exactly N feed_item rows are created and the denormalized counters match actual row counts after a batch of like/unlike operations.
  • Definition of done: two test users follow each other, post, like, and comment; both feeds and notification lists reflect it correctly and quickly, deployed.
  • Rollback: Vercel instant rollback; feed_item is a derived table in principle (rebuildable from post + follow), which is a useful safety net if a fan-out bug ever needs a data fix.

11. Retro — "at 10x"

The explicit scale assumption in the PRD (ADR-1) is exactly what breaks first: a user with a million followers posting once means a million-row INSERT ... SELECT inside a single request — that request times out long before it completes, and it does it on the single most important action in the app (posting). The fix is the one real systems actually use: move fan-out onto an async queue-backed worker (app 7 introduces exactly this infrastructure), and for accounts above some follower threshold, skip fan-out-on-write entirely and merge their posts into followers' feeds at read time instead — a hybrid of both models, giving normal users fast writes and celebrity accounts bounded write cost, at the price of a slightly more complex read path only for the rare high-follower case.