Mobile Design Rounds
Case studyAndroid + iOS9 min read

Design a news feed on Android and iOS: paging, optimistic likes, cache freshness

Last verified . Drafted with AI assistance and checked against official documentation (how this is verified). Part of: The framework · Offline-first guide · Staff-level guide.

TL;DR. The database is the source of truth and the UI only reads from it. Page with an opaque server cursor, never offsets. Store feed order separately from post data so an updated post never jumps position. Make likes optimistic and idempotent (set a state, never toggle). Serve the cache first, then revalidate in the background.

1. Clarify the requirements (about 5 min)

Questions worth asking:

  • What is in a post? (Assume text, one or more images, author, like and comment counts.)
  • Is the order ranked by the server or chronological? (Assume server-ranked, so the client cannot sort it.)
  • Which interactions? (Like, open comments, pull to refresh, infinite scroll.)
  • Must it work offline? (Yes: show the last feed, queue likes.)
  • Do new posts appear live? (Assume a "New posts" pill, not auto-insertion that shifts the list.)

Non-functional (the mobile part):

ConcernTarget
Cold startLast feed visible from cache in under 1s
ScrollNo dropped frames while images load
DataRespect cellular data; no wasted prefetch
ConsistencyNo duplicate or missing posts, no jumping while reading
OfflineBrowse cached feed, like and unlike without a connection

Out of scope: ranking algorithm, ads, video playback, stories. Say it out loud.

2. High-level architecture (about 10 min)

observesurlsfetchloadpagestapssendUICompose / SwiftUIState holderViewModelFeed repositorypaged streamLocal databaseposts + feed_itemsImage pipelinememory, disk, networkImage CDNsized variantsPage loadercursor, mediatorREST APIcursor + likesLike queueidempotent PUT/DELETE
News feed architecture: the UI observes posts and feed order in a local database; a mediator fetches cursor pages; images use a three-tier cache.

Official architecture mapping: Compose screen plus ViewModel form the UI layer (the ViewModel is the state holder); FeedRepository is the data layer entry point, with the local DB and the remote loader as data sources; no domain layer (add use cases only if logic is shared). See the Android architecture recommendations.

Same rule as any offline-first design: the UI never talks to the network. Network results are written to the database, and the database drives the UI. This gives you cold-start-from-cache, offline reading, and one place to resolve conflicts.

3. Data model and API

The key modeling decision: separate posts from feed order

The feed is server-ranked, so the same post can move between refreshes. If you store order on the post row itself (or sort by createdAt), then an update to one post can reorder the list, and the user sees things 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 a page is stored.

REST

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

Two details that matter in an interview:

  • Opaque cursors, not offsets. If new posts arrive between page fetches, offset paging skips or repeats items. A cursor anchors to a position, so the client just stores nextCursor and sends it back.
  • PUT and DELETE for likes, not a toggle. A toggle endpoint is unsafe to retry: if the response is lost and the client retries, the like flips back. Setting an explicit state is idempotent, so retries are always safe.

4. Loading pages (the core of the read path)

Android: use Paging 3 with a RemoteMediator. Room supplies the PagingSource, and the mediator fetches from the network only when the cache runs out. The mediator is data layer code: FeedRepository builds the Pager, and the ViewModel exposes pagingData.cachedIn(viewModelScope) as its screen state. Snippets use Room rows (FeedRow) directly for brevity; map them to UI models in the repository in a larger app.

@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) }
    }
}

Points to say out loud:

  • 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 the stale-while-revalidate switch. Fresh cache: show it immediately. Stale cache: show it anyway and refresh behind it.
  • Prefetch distance. Configure PagingConfig(pageSize = 20, prefetchDistance = ...) so the next page starts loading before the user reaches the end.

iOS: there is no official paging library. You write a small loader that keeps one in-flight task, stores nextCursor, and triggers 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
        }
    }
}

5. Optimistic likes (the hard part)

  1. The user taps like. In one transaction, set likedByMe = true, likeCount + 1, and pendingLike = true. The UI updates instantly.
  2. Enqueue the network write (PUT /like) as durable work (an official lazy write: local first, network queued; this is business-oriented work, so WorkManager rather than viewModelScope, and the worker calls FeedRepository, never the DAO): WorkManager on Android; on iOS, a small persisted outbox drained on send, foreground, and reachability changes.
  3. On success, store the server's returned likeCount and clear pendingLike.
  4. On a permanent failure, roll back the local change and show a small error.
  5. On a transient failure, retry with backoff. Because the call is idempotent, retries are safe.

The subtle bug to call out: a refresh arrives while a like is still pending. If the server snapshot simply overwrites the row, the like visually flips off, then back on when the write lands. The fix is upsertKeepingLocalIntent: when storing server data, any row with pendingLike != null keeps its local likedByMe until the write is confirmed.

Rapid taps: collapse them. Keep one pending intent per post (last state wins) instead of queueing like, unlike, like.

6. Cache invalidation and freshness

SituationBehavior
App opens, cache under 10 min oldShow cache, no refresh
App opens, cache olderShow cache, refresh in the background, swap when done
Pull to refreshFetch page 1 with no cursor, replace the feed in one transaction
New posts existPoll or push a hint, show a "New posts" pill, insert only when tapped
Post edited or deleted elsewhereNext page fetch overwrites that post row; deletes handled by tombstones or refresh

Never auto-insert at the top while the user is reading. It shifts content under their thumb. Show the pill instead.

7. Images and scroll performance

Images are usually the bottleneck, not the feed logic.

  • Three tiers: memory cache, disk cache (LRU), network. Use a proven library (Coil or Glide on Android; Nuke or Kingfisher on iOS) instead of writing one.
  • Request the right size. Ask the CDN for a size that matches the view, and downsample on decode. Never decode a full-resolution image to show a thumbnail.
  • Decode off the main thread. 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 a couple of screens ahead; cancel loads for rows the user scrolled past (UICollectionViewDataSourcePrefetching on iOS, with cancelPrefetchingForItemsAt; Apple notes prefetch is not called for every cell, so cellForItemAt must cope with data that is not ready).
  • Respect data. Skip background image prefetch on cellular or in data-saver mode. Prefetching makes scroll feel instant, but it spends the user's data plan.
  • Stable IDs. Key list items by post ID so that appends and refreshes keep scroll position.

8. Failure modes

ScenarioHandling
Page request fails mid-scrollShow a retry row at the end of the list; do not silently stop
Offline on launchShow cached feed with an "Offline" banner; likes queue
Duplicate post across pagesUpsert by post ID; feed order keyed by (feedId, postId)
Refresh while a like is pendingKeep local intent until confirmed (section 5)
Cursor expiresServer returns an error code; client restarts from page 1
Process killed mid-likePending flag and queued write are persisted; they resume on next launch
Huge cacheCap rows (for example, keep the latest few hundred) and evict the oldest

9. How to build a news feed on iOS (Swift)

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
Durable like queueWorkManagerPersisted outbox, drained on triggers (no guaranteed background run after force-quit)

The data model and API are identical. The main difference is that on iOS you own more of the paging and queue plumbing.

10. What changes by level

  • Mid-level: database as source of truth, pagination with a cursor, infinite scroll trigger, basic image caching.
  • Senior: separate feed-order storage, optimistic and idempotent likes, refresh-clears-after-success, the pending-intent merge, and data-aware prefetching.
  • Staff: ranking changes mid-session, paging consistency across devices, experimentation and feature flags, metrics (time to first content, scroll jank rate, cache hit rate), and a plan to share the feed module across teams and surfaces.

11. Follow-up questions to expect

  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 disrupting scroll? (Pill, anchor on the first visible item.)
  3. How would you add video to the feed? (Autoplay the most visible item, prefetch the first segment, pause off-screen.)
  4. How do you keep multiple screens showing the same post in sync? (One posts table observed by every screen.)
  5. How do you measure feed health? (Time to first content, jank rate, cache hit rate, failed-write rate.)

Further reading (official docs)

Verification notes (last verified 13 Oct 2026)

Android architecture audited against the official guidance on 2026-10-13; remaining deviation: snippets expose Room rows to the UI, and the sync style (pull-based, hybrid with the pill) is not spelled out in official terms. The RemoteMediator shape follows the Android docs; lastUpdated(), lastCursor() and upsertKeepingLocalIntent are app-defined helpers, not library APIs. Paging 3 names (InitializeAction, LoadType, PagingConfig) were checked by eye only; the Android docs pages were not re-fetched. PUT/DELETE idempotency follows HTTP semantics (RFC 9110). The iOS sections have not yet been reviewed by an iOS engineer. Unverified: how List is implemented on each OS version, and whether onAppear in List fires early for prefetch-like loading). The 10-minute cache window and prefetch thresholds are example values, not rules. Kotlin snippets 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. The retrofit2.HttpException import is unverified: missing cached retrofit2 artifacts (stubbed in the check). Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6) with stubs only for app-defined placeholders.

Keep reading

Follow new case studies

Subscribe with any RSS reader. Found a mistake? Open an issue on GitHub.