Designing a Mobile News Feed App
An offline-capable, infinitely scrolling feed for 500M users — built one problem at a time.
An offline-capable, infinitely scrolling feed for 500M users — built one problem at a time.
The chapter is reorganized as a chain of problems and the decision that resolves each one. Diagrams are original redraws — read the caption first, then trace the numbered steps. Anything marked [+] is context added beyond the source to fill a gap; everything else summarizes the chapter itself.
A feed is a list — the hard part is everything around it#
A news feed is a continuously updating list of content items — status updates, photos, videos, links. Facebook, Instagram, and X all brand it differently, but the mechanics are the same: a scrollable list, per-post detail views, endless scrolling, and social actions (like, comment, share).
Before designing anything, pin down scope. The interview conversation converges on a specific system, and the constraints below drive every later decision.
Functional requirements#
- Browse a feed of posts from others, ordered as the server returns them.
- Like and share posts directly inline.
- Compose posts with rich text, images, and video.
- Open a detail view by tapping a post.
- Infinite scroll — fetch more posts as the user reaches the bottom.
- Offline mode — previously loaded content is readable without a connection.
Non-functional requirements#
Real-time updates and push notifications, pre-fetching before app open, and authentication (users are assumed logged in). Cutting these keeps the design focused — name them so the interviewer knows the omission is deliberate.
Three screens#
The whole app reduces to three surfaces, and every architecture component below exists to serve one of them.
+
opens Create Post; tapping any feed item opens Post Detail.Every interaction is client-initiated, so REST fits#
List the interactions first: the client requests the feed on open, sends new posts, fetches post details on tap, and notifies the backend of likes and shares. In all four, the client starts the exchange and the server responds. Nothing needs the server to push unprompted — real-time was cut from scope.
That request/response shape, plus the global scale and multiple future client types, points to HTTP with REST. It is universally understood, stateless (each request self-contained, which makes horizontal scaling trivial), and can lean on HTTP caching.
| REST wins on | REST costs you |
|---|---|
| Familiarity — easy to build and maintain. Stateless servers scale horizontally with no session affinity. Built-in HTTP caching cuts server load. | Versioning gets messy as the API evolves without a strategy. Statelessness re-sends auth/session data every request — extra bytes and connection-setup latency on mobile. Over-fetching and under-fetching (multiple round trips for one screen). |
GraphQL would solve over/under-fetching by letting the client name exactly the fields it wants in one request. It is a reasonable alternative, but REST is the industry default — Meta's Graph API, Reddit's API, and X's API all use it — so this design stays with REST.
For encoding, the choice is JSON. It is human-readable, smaller than XML, and natively supported everywhere. Protocol Buffers would be smaller and faster on the wire, but this app is not performance-critical enough to justify the setup cost and the extra knowledge every developer would need.
HTTP + REST for client-server communication, JSON as the data format.
Four endpoints cover the whole app#
All endpoints are versioned (/v1/) so breaking changes can ship in a new version without
disturbing old clients. All carry an Authorization: Bearer <token> header and assume
Accept: application/json.
1 · Fetch the feed#
GET /v1/feed?after=<cursor>&limit=30 // empty body → 200 OK, FeedApiResponse
The response is deliberately lightweight. Each item is a PostPreview — not the full post.
Instead of shipping every attachment, it carries an attachmentCount and a single
attachmentPreviewImageUrl, plus interaction state like liked and
likeCount. Just enough for a rich list, nothing more.
List views use a lean PostPreview (thumbnails, low-res, minimal fields); the detail view uses
a full PostDetail (complete content, all attachments, high-res) loaded only when the user taps
in. This list/detail split saves bandwidth, memory, and CPU — especially on low-end phones. It is the same
pattern behind most master-detail mobile UIs.
Offset pagination (?page=3) degrades as you scroll deeper and breaks when new posts shift the
window — you get duplicates or gaps. Cursor pagination (?after=<id>)
uses an indexed column for stable, efficient paging over constantly changing content. That is why the feed
endpoint returns a cursor.
2 · Create a post#
POST /v1/posts // body: NewPostRequest → 201 Created (no body)
The client sends a NewPostRequest with a client-generated requestId, the content
string, and a list of attachments. Two subtleties:
- requestId ≠ postId. The client's
requestIdjust identifies this attempt; the server mints the realpostIdandcreatedAt. The backend stays the source of truth (see §04). - Attachments are uploaded first. Media is uploaded to the server in a separate step, and the
post request only references the resulting
contentUrl. Splitting upload from post creation handles large files far more gracefully than one giant multipart request. [+] This also lets the client show upload progress and retry a failed image without re-posting.
3 · Fetch post detail#
GET /v1/posts/{postId} // empty body → 200 OK, PostDetailApiResponse
The postId lives in the path, per REST convention. The returned PostDetail carries
everything PostPreview omitted: full content, shareCount, and the complete
attachments array with full-resolution URLs.
4 · Record an interaction#
POST /v1/posts/{postId}/interactions // body: PostInteractionRequest → 201 Created
Body carries a client requestId (a throwaway UUID for deduplication) and a type enum
— LIKED, REMOVED_LIKE, SHARED.
The backend reads the user from the auth token, not the request body. That saves bytes and — more importantly — removes a class of security bugs where a spoofed body user ID disagrees with the authenticated user.
The server, not the client, owns IDs and timestamps#
Clients never generate postId or createdAt. Centralizing this on the backend buys
four things at scale:
CURRENT_TIMESTAMP on
insert.Distributed systems solve the unique-ID problem with schemes like X's Snowflake: a 64-bit ID packing a timestamp, a machine ID, and a per-millisecond sequence number. [+] Because the timestamp sits in the high bits, Snowflake IDs sort chronologically and stay collision-free across many servers — you get ordering for free without a central counter.
A layered client with data flowing one direction#
Two external pieces sit outside the app. The Backend is the RESTful service that serves data and permanently stores posts and interactions — it holds most business logic (feed generation, post creation, interaction processing). A CDN distributes static content (profile images, media) from servers physically near each user, which speeds first paint, absorbs traffic spikes, and offloads the backend. At 500M users, the CDN is not optional.
Inside, the client splits into two layers. The UI layer displays posts, handles taps, and shapes data for the screen. The data layer is the backbone — it owns client-side business logic for how posts are created, modified, retrieved, and cached.
Data flows one way — sources → repositories → UI — while user actions travel back through defined channels. Reactive streams propagate changes automatically. The result is predictable state with no tangled two-way bindings. [+] On Android this is Coroutines + Flow; on iOS it is Combine.
The data layer follows a repeatable pattern: one Repository per data type (the app's interface to that data and its business logic), backed by zero or more Data Sources.
For posts specifically: a Posts Repository exposes the feed and individual posts; a
Posts Remote Data Source talks to the backend; a Posts Local Data Source
manages on-device storage for offline. A separate User Repository holds the logged-in user's
details and token. Each screen gets a State Holder (think ViewModel) that owns UI state and
screen logic, and a shared Navigator moves between screens, passing values like a
postId.
The feed's state is a NewsFeedUiState holding a list of PostPreviewUiModel — not
the PostPreview from the network. Keeping a dedicated UI model lets the screen carry only what
it renders (often reshaped or pre-processed), keeps memory lean, and lets presentation evolve independently
of storage or the backend.
Don't explain what a UI layer is — the interviewer assumes fundamentals. Spend your time on the data layer and on why you chose each trade-off. Keep the architecture a solid skeleton, not a finished blueprint; you'll refine it as the discussion deepens.
First login: watch the data climb the stack#
Tracing a single scenario makes the architecture concrete. When a user logs in for the first time, the local cache is empty, so the app must round-trip to the backend and populate local storage before the screen can render.
- Login completes and the Feed State Holder subscribes to the Posts Repository's stream.
- The repository checks the local source — it's empty, so it reaches out to the network.
- The Remote Data Source calls the backend and receives the feed.
- The repository writes the response into the Posts DB through the local source.
- Writing the DB emits the new state into the posts stream.
- That emission propagates up to the UI, and the screen renders the feed.
The key move: the UI never subscribes to the network. It subscribes to the local stream, and the network's only job is to fill that local store. This is the single-source-of-truth idea that §07 makes explicit.
Pause and confirm every functional and non-functional requirement is covered by the skeleton. Fixing a missing use case now is cheap; discovering it mid-deep-dive is not.
Offline isn't a feature bolted on — it's the data model#
Offline support is a core requirement, and it reshapes storage. Local storage serves two purposes: browsing cached posts when the network is bad or absent, and instant loads of already-seen content (no round trip).
Pick a relational database#
| Relational DB ✓ | Non-relational DB | Key-value store |
|---|---|---|
| Posts are well-structured and need filtering, sorting, and possibly search. Relational gives integrity, consistency, and scalable querying. | Posts are structured, so the flexibility of NoSQL buys nothing here — and we can't tolerate inconsistency locally. | Not built for large or structured data. No relationships, no advanced queries, no transactions out of the box; risks redundancy and degradation. |
A relational database wins. [+] On Android that's typically Room over SQLite; on iOS, Core Data or SQLite directly. For most mobile apps, relational is the safe default unless a specific requirement pushes elsewhere.
Keep the cache lean — a hybrid eviction policy#
Storing every post forever wastes device resources. Combine three policies so the cache stays fresh and useful:
Sensible starting defaults, all tunable from the backend via analytics: 100–200 MB max cache, 15-day TTL, and a floor of 50 posts (2–3 screens).
The local database — not the network — is the one truth the client reads from. Concretely:
(1) all UI reads come from the local DB, never directly from the network;
(2) network responses update the DB, which then propagates to the UI; (3)
user interactions are recorded in the DB before being sent to the server. Data always flows
Backend → Local Storage → UI, online or off. The DB is scoped to the current user and cleared
on logout for security.
When someone pulls to refresh while offline: the repository tries the network, fails, and that failure propagates to the state holder, which shows a connectivity message — while previously cached content stays on screen. The app never goes blank.
Facebook's early iOS app cached feed stories in Core Data, hit scaling limits, and replaced it with a lightweight denormalized disk cache (object serialization) for a large speedup. Instagram keeps a feed-JSON response store plus an image/video cache so content plays back from disk as if from the network. Reddit moved video caching to Android internal storage after external storage proved unreliable on some devices.
Make the tap feel instant, then reconcile in the background#
Waiting for a server round trip before a like registers feels broken. Optimistic writes update the UI immediately, as if the backend already confirmed, and reconcile afterward. When you tap like, the state holder does two things in parallel:
- Update UI state now — the heart fills instantly. This is the optimistic part.
- Tell the repository, which persists the interaction locally and fires the network request.
When the backend later confirms, the UI doesn't change again — it was already updated in step 1.
Use the local DB as an outbox queue#
Optimistic UI alone isn't enough. Every interaction is stored locally until the backend confirms it, so nothing is lost during offline stretches. The local DB acts as an ordered queue; when connectivity returns, the repository drains it in the background.
A new UserInteraction row captures each action with an AUTO INCREMENT id (preserving
order), the postId/userId, an action (LIKED,
UNLIKED, SHARED, BOOKMARKED), a status
(PENDING, FAILED, CANCELED), an updatedAt for the last sync
attempt, and a failureCount. Once an interaction syncs, the repository deletes its row and updates
the corresponding Posts row. Conflicts between multiple interactions on one post resolve by
last-write-wins. In the design, tables surface as DAO components — a
Posts DAO and a User Interactions DAO, both over the one Posts DB.
When syncs fail — retry, then decide#
- A failed request marks the interaction
FAILED. - The system retries with exponential backoff — progressively longer delays.
- Retries continue until a max attempt count.
What happens at the cap depends on how much the action matters. Trivial actions get set to
CANCELED and the UI rolls back. Important ones (a failed post submission) warrant notifying the
user to retry manually. If the backend state changed since the optimistic write, that's a
conflict — discard the local change, merge, or ask the user, depending on the data.
| Challenge | How the design handles it |
|---|---|
| Client/backend consistency drift | Backend is SSOT — every response overwrites local Posts to match. |
| Interactions applied out of order | Timestamp each interaction and send them in order for sequential processing. |
| Confusing UI on rollback/conflict | For critical cases, tell the user what happened and what to do next. |
| Retries straining the backend | Exponential backoff, plus optional batching of interactions. |
Instagram built a "Mutation Manager" in Direct to handle network failures and data inconsistencies for optimistic writes.
Store rich text as HTML, render it as native views#
Posts support rich formatting — headers, bold, italics, links, mentions. Content is stored as a String, but rendering it as plain text would disappoint. Two questions follow: what markup, and how to render it.
Don't invent a markup language#
A custom markup language is expensive to build and maintain. Facebook's own attempt (FBML) was deprecated in favor of standard HTML and JavaScript. X keeps a custom text library; Reddit uses a flavor of Markdown. For this app, rolling your own isn't worth it — pick a standard.
| Option | For | Against |
|---|---|---|
| HTML ✓ | Rich formatting; extensible for future features (highlighting, mentions); consistent cross-platform rendering. | Complex for non-technical authors; needs sanitization against XSS; can be heavy to render on low-end devices. |
| Markdown | Simpler for users; converts to HTML for flexible rendering. | Limited formatting for rich layouts; still a syntax users must learn. |
HTML wins for a modern, richly formatted feed. Two guardrails tame it: define HTML best practices so one feature maps to one consistent markup pattern (no five ways to bold text), and validate and sanitize every HTML string to block XSS. [+] Tools: OWASP Java HTML Sanitizer or jsoup on Android; HTMLKit or a purifier library on iOS.
Render with native views, not a WebView#
| Option | For | Against |
|---|---|---|
| WebView | Renders any HTML with minimal effort; identical appearance across platforms. | Higher memory hurts feed scrolling; interactions feel non-native and clash with feed animations. |
| Native UI ✓ | Better scroll performance; consistent with platform gestures and design. | Needs a custom HTML→native parser and more upkeep. |
Because content is limited to formatted text with links and images — and because HTML best practices constrain the input — parse the HTML into native components. The extra parser work pays off in scroll smoothness and a polished, native feel.
A shared Post Content Renderer (backed by an HTML Parser) renders posts natively, and every screen depends on it for consistency and reuse. The Create Post screen gains a Post Editor that encodes rich input to a String via an HTML Encoder.
In 2012 Facebook's iOS app moved from HTML5 to native code for performance. Reddit reported that adopting Server-Driven UI and trimming unneeded response data cut home-feed latency by 12%.
Janky scrolling is usually rendering doing too much on the main thread#
A classic interview curveball: "What causes janky feed scrolling, and how would you fix it?" The answer is a checklist of rendering discipline.
Recycle views, load work off-thread#
View recycling is the biggest lever. Instead of creating a fresh view for every item scrolling
in, reuse ones that scrolled out: pull a matching view from a recycling pool and rebind it with new data. Fewer
allocations and layout passes free the main thread for smooth animation. [+] Use
RecyclerView / LazyColumn on Android and UITableView /
UICollectionView / SwiftUI List on iOS.
Asynchronous loading is built into the data model: images and attachments arrive as URL strings, so they load in the background while the row renders — the main thread never blocks on a decode.
| Culprit | Fix |
|---|---|
| Main-thread blocking (heavy compute, sync I/O or network) | Move all expensive work to background threads. |
| Complex, deeply nested layouts | Flatten the hierarchy; drop redundant/overlapping views. |
| Inefficient state → unnecessary re-renders | Debounce state updates; cache and memoize. |
| Unoptimized images | Use a performant loader; resize to display dimensions if the server doesn't. |
Adapt media quality to the device#
A high-end phone scrolls smoothly where a low-end one stutters — CPU, memory, GPU, network, and battery all matter. So adapt image and video quality dynamically to current conditions. Define quality tiers and let the client request the right one:
This requires the backend to serve multiple pre-processed variants of each asset, or generate the requested quality on demand.
Facebook's Litho framework moved UI work off the main thread and improved News Feed scrolling up to 20% (as high as 42% in some list subviews). Pinterest cut video startup latency ~36% by embedding manifest files in the API response so the player skips extra round trips. Reddit cancels video prefetch downloads when the user scrolls too fast, deferring non-essential work during the scroll.
Recap#
Make the local database the source of truth. Once the UI only ever reads from local storage — and the network's sole job is to fill it — offline mode, instant optimistic writes, smooth scrolling, and eventual consistency all fall out of the same design rather than being bolted on separately.
Stretch extensions worth mentioning if time allows: comments, push/live updates, on-device Full-Text Search, post editing after sync, scheduled posts, intelligent pre-fetching, and multi-language support.
Further reading#
Every link below was fetched and confirmed live on August 13, 2026. Two source references could not be verified and are flagged at the end.
requestId is
collision-free — background for interaction deduplication.instagram-engineering.com Medium domain would
not respond; omitted rather than linked as live.