Study Notes
Note

Designing a Mobile News Feed App

An offline-capable, infinitely scrolling feed for 500M users — built one problem at a time.

21 min read
Mobile System Design · Chapter 03

An offline-capable, infinitely scrolling feed for 500M users — built one problem at a time.

🧭
How to read this

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.

01

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#

  1. Browse a feed of posts from others, ordered as the server returns them.
  2. Like and share posts directly inline.
  3. Compose posts with rich text, images, and video.
  4. Open a detail view by tapping a post.
  5. Infinite scroll — fetch more posts as the user reaches the bottom.
  6. Offline mode — previously loaded content is readable without a connection.

Non-functional requirements#

Scalability500M monthly active users, global, across wildly varied networks.
PerformanceSmooth scrolling and fast post creation on every device tier.
ReliabilityWorks on poor connectivity via effective caching.
Data efficiencyMinimize data use for capped plans — cache smart, load selectively.
ConsistencyEventual consistency for offline actions; reliable sync on reconnect.
🚫
Explicitly out of scope

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.

+ News Feed POST Create Post Post Detail tap + tap post
Figure 1 — The three screens and how a user moves between them. The floating + opens Create Post; tapping any feed item opens Post Detail.
02

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 onREST 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.

✅
Decisions locked in

HTTP + REST for client-server communication, JSON as the data format.

03

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.

📐
Two models for one thing [+]

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.

📄
Why cursor pagination, not offset

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:

  1. requestId ≠ postId. The client's requestId just identifies this attempt; the server mints the real postId and createdAt. The backend stays the source of truth (see §04).
  2. 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 request never carries a user ID

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.

04

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:

ConsistencyOne authority enforces uniform formats and validation.
ReliabilityDevice clocks can be wrong or tampered with; server time is trusted.
UniquenessNo collisions even under massive concurrent writes.
DB integrationUse auto-increment keys and 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.

05

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.

🔄
Unidirectional Data Flow + Reactive programming

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.

CLIENT UI LAYER Feed screen Feed state holder Detail screen Detail state holder Create screen Create state holder Navi- gator Media Loader CDN HTTPS DATA LAYER Posts Repository Posts Local Source Posts Remote Source Posts DB stream Backend HTTPS
Figure 2 — The full client. Arrows are data flow, not dependencies. The local source (backed by the Posts DB) and the remote source both feed the repository, which streams up to the state holders. Media bypasses this path and loads straight from the CDN.

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.

🎨
UI models ≠ data models

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.

💡
Interview tactic

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.

06

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.

  1. Login completes and the Feed State Holder subscribes to the Posts Repository's stream.
  2. The repository checks the local source — it's empty, so it reaches out to the network.
  3. The Remote Data Source calls the backend and receives the feed.
  4. The repository writes the response into the Posts DB through the local source.
  5. Writing the DB emits the new state into the posts stream.
  6. 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.

✔️
Checkpoint before the deep dive

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.

07

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 DBKey-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:

LRUKeep recently viewed posts — most likely to be reopened.
TTLExpire posts after a set age so nothing goes stale.
Min thresholdAlways retain a baseline set for offline viewing.

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).

🎯
Single Source of Truth (SSOT)

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.

🔍
How the big apps do it

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.

08

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:

  1. Update UI state now — the heart fills instantly. This is the optimistic part.
  2. 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.

Feed Screen State Holder Posts Repository Local Data Source Remote Source Backend Interactions DAO Posts DAO 👤 like 1.1 1.2 2 3 4 5 6 7
Figure 3 — Green = instant local feedback, blue = writes syncing outward, purple/dashed = async reconciliation. Step 7's DB update produces no new UI emission because the state was already set optimistically in 1.1.

When syncs fail — retry, then decide#

  1. A failed request marks the interaction FAILED.
  2. The system retries with exponential backoff — progressively longer delays.
  3. 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.

ChallengeHow the design handles it
Client/backend consistency driftBackend is SSOT — every response overwrites local Posts to match.
Interactions applied out of orderTimestamp each interaction and send them in order for sequential processing.
Confusing UI on rollback/conflictFor critical cases, tell the user what happened and what to do next.
Retries straining the backendExponential backoff, plus optional batching of interactions.
🔍
From the field

Instagram built a "Mutation Manager" in Direct to handle network failures and data inconsistencies for optimistic writes.

09

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.

OptionForAgainst
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.
MarkdownSimpler 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#

OptionForAgainst
WebViewRenders 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.

🧩
New components this adds

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.

🔍
Precedents

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%.

10

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.

CulpritFix
Main-thread blocking (heavy compute, sync I/O or network)Move all expensive work to background threads.
Complex, deeply nested layoutsFlatten the hierarchy; drop redundant/overlapping views.
Inefficient state → unnecessary re-rendersDebounce state updates; cache and memoize.
Unoptimized imagesUse 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:

ImagesLow ≤480×320 · Medium 720×480 · High ≥1080×720
VideosLow 360p · Medium 720p · High ≥1080p

This requires the backend to serve multiple pre-processed variants of each asset, or generate the requested quality on demand.

🔍
Real numbers from real teams

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.

11

Recap#

REST + JSONClient-initiated interactions make stateless REST the natural fit.
Cursor paginationStable, efficient paging over a feed that keeps changing.
List vs. detail modelsLean PostPreview for the feed, full PostDetail on tap.
Server owns IDsConsistency, uniqueness, and trusted timestamps at scale.
Layered + UDFOne-way data flow through repositories keeps state predictable.
Relational local DBStructured, queryable, transactional — the offline backbone.
Optimistic writesInstant UI, queued locally, reconciled with backoff and last-write-wins.
HTML → native renderRich content stored as sanitized HTML, parsed to native views.
Recycle + asyncReuse views and load media off-thread to kill jank.
Adaptive qualityMatch media resolution to device and network.
🪄
The single idea underneath it all

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.

12

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.

APIs & protocols
[1]Meta Graph APIReference implementation of a large-scale REST-style social API — the model this chapter's endpoints echo.
[2]Reddit APIA public REST API you can read end to end; good for seeing endpoint and pagination conventions in the wild.
[3]X (Twitter) APIAnother production feed API. Changed materially — now pay-per-use / credit pricing, restructured since the source was written.
IDs & identifiers
[4]UUIDWhy a random client-generated requestId is collision-free — background for interaction deduplication.
[5]Snowflake IDThe 64-bit timestamp+machine+sequence scheme behind server-side, time-sortable, collision-free IDs.
Performance at scale
[6]Supporting global events at FacebookHow CDNs and infra absorb traffic spikes — context for adding a CDN at 500M users.
[7]Making News Feed ~50% faster on iOSThe Core Data → denormalized disk-cache story cited for offline caching; also covers the earlier HTML5 → native switch.
[15]Improving Android video with LithoWhere the up-to-20% (and 42% in subviews) scroll gains come from — async layout and fine-grained recycling.
[16]Improving ABR video at PinterestThe manifest-embedding trick that cut video startup latency ~36% by removing round trips.
[9]Improving video playback with ExoPlayer (Reddit)Reddit's caching, prefetch-cancellation, and buffer-tuning experiments with production A/B numbers.
[14]Reddit adopts Server-Driven UIThe 12% home-feed latency reduction cited for trimming response data and moving rendering decisions server-side.
Rich text & markup
[11]FBML deprecation noticePrimary evidence that a custom markup language wasn't worth maintaining — Facebook retired FBML for standard HTML/JS.
[12]twitter-textX's open-source text-parsing library — the "custom approach" alternative to HTML/Markdown.
[13]Reddit-flavored MarkdownExample of choosing an existing markup standard. Now a stub that redirects to Reddit's Help Center formatting guide.
Stretch topics & platform docs
[20]Full-Text SearchFor the offline-search extension — how to index and query cached posts locally.
[21]Android background tasksScheduling work — foundation for scheduled posts and background sync of the interaction queue.
[24]Android multi-language supportLocale directories, RTL mirroring, and BidiFormatter — for the multi-language extension.
[25]iOS multi-language supportApple's guidance for localizing and internationalizing the same extension on iOS.
Could not verify
[8]Instagram background data prefetching — unreachable (fetch timed out repeatedly)Source cited for Instagram's feed-JSON and media disk caching. The instagram-engineering.com Medium domain would not respond; omitted rather than linked as live.
[10]Instagram Direct: reliable & fast messaging — unreachable (fetch timed out repeatedly)Source cited for Instagram's Mutation Manager for optimistic writes. Same domain, same timeout — could not confirm it resolves.