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:
| Screen | Purpose |
|---|---|
| Feed (home) | Infinite-scroll chronological posts from followed users |
| Profile | A user's own posts, follow/unfollow button |
| Post detail | Full post + comments |
| Notifications | Unread/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
| Method | Path | Body | Auth |
|---|---|---|---|
| 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:
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_countis a column, not a liveCOUNT(*), 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_itemis 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_itemrows 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
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_itemrows 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_itemis a derived table in principle (rebuildable frompost+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.