Mobile Design Rounds
Build guideAndroid + iOS10 min read

Build it: a news feed on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design a news feed on Android and iOS: paging, optimistic likes and fresh cache data · The framework.

This is the implementation material behind the news feed interview case. It covers:

  • the tables and the REST shape
  • the mediator (the code that fetches pages and fills the database) and the loader code
  • the like flow in full
  • freshness, images, failure handling and the iOS differences

None of it belongs in the 45 minutes. I draw it only if asked.

The snippets use Room 2.x (androidx.room). The official Room guide now shows Room 3 (androidx.room3, KSP only). Its release notes replace runInTransaction with withWriteTransaction. The design is the same either way.

1. Data model and API

The key modeling decision: separate posts from feed order

The server ranks the feed, so the same post can move between refreshes. Suppose you store the order on the post row itself, or sort by createdAt. Then an update to one post can change the order of the list, and the user sees posts jump.

Use two tables:

@Entity(tableName = "posts")
data class PostEntity(
    @PrimaryKey val id: String,
    val authorName: String,
    val body: String,
    val imageUrl: String?,
    val likeCount: Int,
    val likedByMe: Boolean,
    val pendingLike: Boolean? = null,   // local intent not yet confirmed by the server
)

@Entity(tableName = "feed_items", primaryKeys = ["feedId", "postId"])
data class FeedItemEntity(
    val feedId: String,        // "home"
    val postId: String,
    val position: Int,         // the server's rank order, assigned when the page is stored
    val nextCursor: String?,   // cursor that follows this item's page (remote key)
)

Updating a post changes only posts. The list order comes from feed_items.position, which changes only when the app stores a page.

REST

GET  /v1/feed?cursor=<opaque>&limit=20     → { items: [...], nextCursor: "..." | null }
GET  /v1/feed/head?since=<newestPostId>    → { newCount: 3 }       (head check for the "New posts" pill, not the paging cursor)
PUT  /v1/posts/{id}/like                   → { likeCount, likedByMe: true }   (idempotent)
DELETE /v1/posts/{id}/like                 → { likeCount, likedByMe: false }  (idempotent)

Two details I would make sure to say:

  • Cursors, not offsets. A cursor is a saved place in the list that the server gives the app. The app never reads it. If new posts arrive between page requests, offset paging skips or repeats items. A cursor points to a fixed place, so a new post at the top does not move page 2. The app only stores nextCursor and sends it back.

- A cursor does not stop a re-rank from repeating posts. - For a ranked feed, the server has to keep the ranked list fixed behind the cursor, or the app has to remove repeats by post ID. - I would ask which one the backend does, and remove repeats anyway.

  • PUT and DELETE for likes, not a toggle. A toggle endpoint is not safe to retry. If the response is lost and the app retries, the like flips back. Setting an explicit state is idempotent (doing it twice has the same result as doing it once), so retries are always safe.

2. Loading pages

Android: use Paging 3 with a RemoteMediator. Room supplies the PagingSource, and the mediator fetches from the network only when the cache (the saved copy of the feed in the database) runs out.

The mediator is data layer code. FeedRepository builds the Pager, and the ViewModel exposes pagingData.cachedIn(viewModelScope) as its screen state. The Paging overview describes the Pager as part of the ViewModel layer, and building it in the repository is a common variant.

The snippets use Room rows (FeedRow) directly to stay short. In a larger app, convert them to UI models in the repository. The library side of this contract is in the pagination library case study.

// Shape adapted from the Paging 3 network-and-database sample (Apache License 2.0),
// https://developer.android.com/topic/libraries/architecture/paging/v3-network-db
@OptIn(ExperimentalPagingApi::class)
class FeedMediator(
    private val db: AppDatabase, private val api: FeedApi,
) : RemoteMediator<Int, FeedRow>() {

    override suspend fun initialize(): InitializeAction =
        if (System.currentTimeMillis() - db.feedDao().lastUpdated() < TimeUnit.MINUTES.toMillis(10))
            InitializeAction.SKIP_INITIAL_REFRESH      // show the cache, no spinner
        else InitializeAction.LAUNCH_INITIAL_REFRESH   // stale: refresh, appends wait for it

    override suspend fun load(type: LoadType, state: PagingState<Int, FeedRow>): MediatorResult {
        val cursor = when (type) {
            LoadType.REFRESH -> null
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> db.feedDao().lastCursor("home")
                ?: return MediatorResult.Success(endOfPaginationReached = true)
        }
        return try {
            val page = api.feed(cursor, limit = 20)
            db.withTransaction {
                if (type == LoadType.REFRESH) db.feedDao().clearFeed("home")   // after the network succeeded
                db.postDao().upsertKeepingLocalIntent(page.items)
                db.feedDao().insertItems(page.toFeedItems("home"))
            }
            MediatorResult.Success(endOfPaginationReached = page.nextCursor == null)
        } catch (e: IOException) { MediatorResult.Error(e) }
          catch (e: HttpException) { MediatorResult.Error(e) }
    }
}

What I would point out:

  • Refresh clears only after the network succeeds, inside one transaction. If the request fails, the user keeps the old feed instead of an empty screen.
  • initialize() is where I decide between showing the cache as it is, or showing it and refreshing in the background (show old data first, then refresh it). Per the Paging guide, LAUNCH_INITIAL_REFRESH also holds back prepend and append until the refresh succeeds. The guide's own sample uses a one-hour window; my ten minutes is an example.
  • Prefetch distance: PagingConfig(pageSize = 20, prefetchDistance = 10), so the next page starts loading before the user reaches the end. Both numbers are examples, and I would adjust the distance to how fast people scroll.

iOS: Apple has no paging library like Paging 3. You write a small loader that keeps one running task and stores nextCursor. The loader starts when the user is within about 5 to 10 rows of the end:

// Sketch: cursor loader (call loadMoreIfNeeded from each row's onAppear, or from UIKit prefetching)
@MainActor final class FeedLoader {
    private var task: Task<Void, Never>?
    func loadMoreIfNeeded(currentIndex: Int, total: Int) {
        guard task == nil, total - currentIndex <= 8, let cursor = store.nextCursor else { return }
        task = Task {
            defer { task = nil }
            if let page = try? await api.feed(cursor: cursor) { await store.append(page) }  // dedupe by post ID
        }
    }
}

3. Optimistic likes

  1. The user taps like. In one transaction, set likedByMe = true, likeCount + 1, and pendingLike = true. The UI updates instantly.
  2. Add the network write (PUT /like) to the outbox (a saved record of work the server has not confirmed), so it survives when the process stops. - On Android the outbox is run by WorkManager, and the worker calls FeedRepository, never the DAO (the database access object). - On iOS it is a small saved outbox. The app sends it when you add an entry, when the app comes to the foreground, and when the network returns. - Here the outbox is the pendingLike flag plus the saved write. - The chat app case study builds the same pattern for messages.
  3. On success, store the server's returned likeCount and clear pendingLike.
  4. On a permanent failure (a 4xx other than 408 and 429), roll back the local change and show a small error.
  5. On a temporary failure (an IOException, 408, 429 or 5xx), retry with backoff (wait longer after each failure). Follow Retry-After when the server sends it. Because the call is idempotent, retries are safe.

The hard bug: a refresh arrives while a like is still pending. Suppose the server data simply replaces the row. Then the like turns off on screen, and turns on again when the write arrives. The fix is upsertKeepingLocalIntent. When the app stores server data, any row with pendingLike != null keeps its local likedByMe until the server confirms the write.

Rapid taps: merge them. Keep one pending change per post (the last state wins) instead of adding like, unlike, like to the outbox.

Keep likedByMe and likeCount together while pendingLike is set. Otherwise the count flips while the flag stays the same.

4. Cache freshness

SituationBehavior
App opens, cache fresher than the window (10 min here, an example value)Show cache, no refresh
App opens, cache olderShow cache, refresh in the background, replace when done
Pull to refreshFetch page 1 with no cursor, replace the feed in one transaction
New posts existPoll (ask the server at intervals) or receive a push hint, show a "New posts" pill, insert only when tapped
Post edited or deleted elsewhereNext page fetch overwrites that post row; a delete arrives as a "this post is gone" marker (a tombstone), or just disappears on the next refresh

I would not insert posts at the top while the user is reading. It moves the content while the user reads. Show the pill instead.

5. Images and scroll performance

Images are usually the slowest part, not the feed logic.

  • Three tiers: memory cache, disk cache (LRU), network. I would use a proven library (Coil or Glide on Android; Nuke or Kingfisher on iOS; check their current major versions) and not write one. The image loading case study covers what is inside one.
  • Request the right size. Ask the CDN (the server network that delivers images) for a size that matches the view, and shrink the image while decoding it. Never decode a full-size image to show a thumbnail.
  • Decode off the main thread (the thread that draws the screen). A frame is about 16ms at 60Hz (8ms at 120Hz). Decoding on the main thread drops frames.
  • Prefetch by position, not by page. Start image loads for rows about two screens ahead, and cancel loads for rows the user already scrolled past.

- On iOS, UICollectionViewDataSourcePrefetching does this (UITableViewDataSourcePrefetching for tables), with cancelPrefetchingForItemsAt for the cancel. - Apple says prefetch is not called for every cell, so cellForItemAt has to work when the image is not there yet. - The same idea is in section 4 of the image loading build guide.

  • Respect data. Skip background image prefetch on cellular or in data-saver mode. Prefetching makes scrolling feel instant, but it uses the user's data plan.
  • Stable IDs. Key list items by post ID so that new pages and refreshes keep the scroll position.

6. Failure modes

ScenarioHandling
Page request fails mid-scrollShow a retry row at the end of the list; do not stop without a sign
Offline on launchShow cached feed with an "Offline" banner; likes wait in the outbox
Duplicate post across pagesUpsert by post ID; feed order keyed by (feedId, postId)
Refresh while a like is pendingKeep the local change until confirmed (section 3)
Cursor expiresServer returns an error code; app starts again from page 1
Process killed mid-likePending flag and saved write are stored; they resume on next launch
Very large cacheLimit rows (for example, keep the latest few hundred) and remove the oldest

7. Android and iOS side by side

ConcernAndroidiOS
Local DBRoomSwiftData / Core Data
PagingPaging 3 + RemoteMediatorYour own cursor loader
ListLazyColumn with paging itemsList (its backing implementation varies by OS version) or LazyVStack (creates rows only as needed); avoid plain VStack for feeds
PrefetchprefetchDistance in PagingConfigUICollectionViewDataSourcePrefetching; in SwiftUI, load from onAppear near the tail
Saved like outboxWorkManagerSaved outbox, sent when triggered (no guaranteed background run after force-quit)

The data model and API are the same. The main difference is that on iOS you write more of the paging and outbox code yourself.

8. More practice prompts

These are the ones I would practice, not a list of what any company asks.

  1. How would you add comments inline? (Nested paged list, same pattern, separate remote keys per post.)
  2. How do you show new posts without moving the list? (Pill, anchor on the first visible item.)
  3. The server changes the ranking during a session. What does the user see? (Keep the loaded list stable, offer the new order with the pill or on refresh, remove repeats by post ID.)
  4. How do you count an impression? (A post is visible long enough, say a set fraction on screen for a set time; group the events and send them later to save battery. The thresholds are product decisions.)
  5. The user opens a post and comes back, or the process is killed. Where do they land? (Restore the position from the last visible post ID and the stored feed order, not from a pixel offset.)

Further reading (official docs)

Builds on: chat app for the outbox, pagination library for the paging contract, image loading for the image pipeline. Offline-first background: offline-first hub.

Verification notes (last reviewed 13 Oct 2026)

this guide is the implementation part of the news feed case, split from the interview page on 2026-10-14. The code blocks are unchanged: all four were diffed byte for byte against the audited code in the page this replaces on 2026-10-14 and match. Prose that describes the code was re-read against it on the same date; the sentence about keeping the count with the pending flag is design advice from audit E, not sourced. Audit fixes applied on 2026-10-13: the cursor claim says a ranked feed needs a pinned list or client dedupe; the like flow states the retry rule (transient: IOException, 408, 429, 5xx, honour Retry-After; permanent: other 4xx; this split is my classification); the FeedMediator carries an Apache-2.0 'adapted from' comment. Checked on 2026-10-14 by fetching: the Paging network-and-database guide (network call before the transaction, clear on REFRESH inside it, LAUNCH_INITIAL_REFRESH makes appends and prepends wait, one-hour sample window); the Room guide (androidx.room3, KSP required, version 3.0.3, no mention of withWriteTransaction) and the Room 3 release notes (runInTransaction becomes withWriteTransaction; they do not mention withTransaction, which the snippet uses as Room 2 KTX); the UICollectionViewDataSourcePrefetching page (prefetch not necessarily called for every cell; cancelPrefetchingForItemsAt cancels, and is optional). Checked on 2026-10-13: the Paging overview (Pager and PagingData sit in the ViewModel layer), the UITableViewDataSourcePrefetching page, LazyVStack laziness and onAppear timing wording in the Apple docs JSON. Unverified: cachedIn and prefetchDistance default behaviour (general Paging knowledge, not found on the overview page); how List is implemented on each OS version; whether onAppear in List fires early enough for prefetch-like loading; the Android data-saver API for skipping prefetch; the retrofit2.HttpException import (retrofit2 artifacts were stubbed in the compile check). lastUpdated(), lastCursor() and upsertKeepingLocalIntent are app-defined helpers, not library APIs. PUT and DELETE idempotency follows HTTP semantics (RFC 9110). The 10-minute cache window, prefetch thresholds and impression thresholds are example values. Kotlin snippets were compile-checked offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), Room 2.8.4, Paging 3.4.2, with stubs only for app-defined placeholders; newer releases exist (Room 2.8.5, Paging 3.5.3) and the attribution comment added to the mediator was not re-compiled. Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6), stubs only for placeholders. Not applied, from audit E: the mediator restarts positions at 0 on APPEND (toFeedItems needs a start position) and overrides load with the parameter named type; the snippet is unchanged on purpose. No iOS engineer has reviewed the iOS sections yet. Links: every external link returned 200 on 2026-10-14, and the anchor #4-views-recycling-and-cancellation matches the heading "4. Views, recycling and cancellation" in the image loading build guide (the earlier text called it section 5). A readability pass on 2026-10-14 split long sentences and paragraphs. A plain English pass the same day used simpler words, defined technical terms and used one word for each idea (outbox, cursor, retry, remove repeats). It changed no fact, link, code block or marker.

Questions or corrections?

Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.

Keep reading

Get the next case study when it is published.

You get an email when a new case study is published, and nothing else.

You can unsubscribe at any time. Privacy.