Design an infinite news feed¶
Design the home feed of a social app: an endless list of posts with text, images and short videos, with likes and comments. It should feel instant and scroll smoothly on low-end phones.
Difficulty: 🟢 Junior to 🟡 Mid · Core topics: cursor pagination, caching, images and video, list performance, optimistic updates
Requirements¶
Functional
- Scroll an endless, ranked feed of posts (text, up to 10 images, or a short video).
- Like a post and see the count change immediately.
- Pull to refresh; see that new posts are available without losing your place.
- Open a post's comments.
Out of scope: creating posts, the ranking algorithm, ads.
Non-functional
| Requirement | Target |
|---|---|
| Time to first content | Under 1 second on a cold start, from cache |
| Scrolling | No dropped frames at 60 Hz on a 2 GB RAM Android phone |
| Data use | Under 10 MB for 5 minutes of scrolling on mobile data |
| Offline | The last feed is readable offline, with images |
Estimates¶
- A post's JSON is about 1.5 KB. A page of 20 is 30 KB (gzipped about 8 KB).
- An image at feed size (1080 px wide, WebP) is 80–150 KB. 20 posts with an average of 1.5 images is about 4 MB a page. Images are the cost; the JSON isn't.
- Decoded, a 1080 × 1350 image is 1080 × 1350 × 4 bytes = 5.8 MB of memory. Ten of those in memory and a low-end phone is in trouble, so decode at display size and limit the memory cache by bytes.
Architecture¶
flowchart TB
subgraph App
LIST[Feed list: lazy, recycled cells] --> VM[Feed view model]
VM --> REPO[Feed repository]
REPO --> DB[(Local DB:<br/>posts, feed pages)]
REPO --> API[API client]
LIST --> IMG[Image loader:<br/>memory + disk cache]
LIST --> VID[Video player pool]
LIKES[Like outbox] --> DB
LIKES --> API
end
API --> FEED[Feed service:<br/>ranked, cursor pages]
IMG --> CDN[Image CDN:<br/>resize, WebP/AVIF]
VID --> VCDN[Video CDN: HLS]
Data model¶
Post
id, author {id, name, avatar_url}, text, created_at,
media [ {type, url, width, height, blurhash, duration_ms?} ],
like_count, comment_count, liked_by_me, version
FeedPage
feed_id ("home"), position, post_id, cursor_after
Posts and their position in the feed are stored separately. The same post can appear in the home feed, a profile and search; there's one row for it, so a like updates everywhere.
API¶
GET /v1/feed/home?after=<cursor>&limit=20
→ { "posts": [...], "next_cursor": "eyJ0IjoxNzMw...", "has_more": true }
GET /v1/feed/home/new-count?before=<first_cursor>
→ { "count": 12 }
PUT /v1/posts/{id}/like (idempotent)
DELETE /v1/posts/{id}/like
PUT and DELETE for likes are naturally idempotent: liking twice is still one like.
Deep dives¶
1. Pagination and prefetching¶
- Cursor pagination. The ranked feed changes all the time, so offsets would show duplicates and skip posts.
- Load the next page when the user is about 5 posts from the end, so it arrives before they get there.
- Pull to refresh doesn't replace the list under the user's thumb. Fetch the new top, show a "12 new posts" pill, and insert only when they tap it or are already at the top.
- Cold start: show the cached feed instantly, then fetch a fresh first page. If the cache is older than a few hours, start at the fresh page instead of mixing old and new.
2. Images¶
flowchart LR
CELL[Cell becomes visible] --> MEM{In memory cache?}
MEM -->|yes| SHOW[Show]
MEM -->|no| DISK{On disk?}
DISK -->|yes| DECODE[Decode at display size<br/>off the UI thread] --> SHOW
DISK -->|no| NET[Fetch from CDN<br/>w=display width × dpr] --> SAVE[Save to disk] --> DECODE
CELL2[Cell scrolled away] --> CANCEL[Cancel fetch]
- Request the right size from the CDN:
?w=412&dpr=2.6&fmt=webp. The server knows the image's aspect ratio, so the cell has its final size before the image loads: no layout jumps. - Show a blurhash placeholder decoded from the post JSON.
- Prefetch images for the next few posts, on Wi-Fi more eagerly than on mobile data.
- Memory cache limited by bytes, not count, and cleared when the OS reports memory pressure.
3. Video¶
- One player pool of 2 or 3 players, reused by cells. Creating a player per cell runs out of decoders and memory.
- Autoplay muted the video that's more than 50% visible and centred; pause the rest.
- Use HLS or DASH with adaptive bitrate. Start at a low rendition for a fast first frame.
- Preload only the first second of the next video, and only on Wi-Fi or if the user allowed it.
4. Smooth scrolling¶
- Lazy, recycling lists (
ListView.builder,RecyclerView,LazyColumn,UICollectionView) with stable keys (the post ID). - Keep cells cheap: no layout passes that depend on loaded content, no work in
buildoronBindbeyond binding data. - Parse JSON off the UI thread for large pages.
- Measure on the cheapest phone you support, in profile or release mode, not on a flagship in debug.
5. Optimistic likes¶
sequenceDiagram
participant U as User
participant DB as Local DB
participant API
U->>DB: liked_by_me = true, like_count + 1
DB-->>U: heart turns red at once
DB->>API: PUT /posts/42/like
alt success
API-->>DB: { like_count: 1031, version: 8 }
else error
API-->>DB: failure
DB-->>U: revert heart, show a small error
end
Likes are low-risk, so the UI changes instantly. If the user taps like, unlike, like quickly, only send the final state after a short debounce.
Failure modes¶
| What happens | Result |
|---|---|
| Offline at launch | Cached feed with images from the disk cache; a banner says you're offline |
| Next page fails | A "Retry" row at the end of the list, not a full-screen error |
| A post was deleted after it was cached | Server omits it on refresh; a tap on a stale one shows "This post is no longer available" |
| Low memory | Memory image cache cleared; images reload from disk as you scroll |
| Like fails | Heart reverts with a short message |
Trade-offs¶
- Prefetch more vs save data: prefetching makes scrolling feel instant but costs data and battery for posts never seen. Tie the amount to the network type and the user's data-saver setting.
- Cache the whole feed vs only the first page: caching more allows longer offline reading but shows staler content. Most apps keep a few pages.
- Server-driven layout vs fixed cells: server-driven UI lets you ship new post types without an app release but is harder to make fast and test. Use it for some modules, not every cell.
- Exact counts vs approximate: "1.2K likes" can be cached longer than an exact count, which lightens the load on the server.
Interview follow-ups¶
- How do you track which posts were seen, for ranking? Record impressions when a post is at least 50% visible for 1 second, batch them locally and upload every 30 seconds or when the app goes to the background.
- How would you add ads? Ads come from a separate service and are inserted at positions the feed response marks, so a slow ad server never delays the feed.
- How do you make the first launch fast with no cache? Ship a small first page in the response to the login or config call, lazy-load everything below the fold, and make sure the first frame doesn't wait for analytics or remote config.
Related: Building blocks: images · Building blocks: pagination