iosNudge

Design an Infinite-Scroll News Feed

MediumSystem designArchitectureNetworkingPersistencePerformance 45 min read

Requirements

You design the home feed of a social app. The user scrolls, sees posts with text and photos, and taps "like". The feed must feel instant, even on a bad network.

Functional

  • Show a list of posts: author, text, photos, like count, comment count.
  • Infinite scroll: load the next page when the user gets near the end.
  • Pull to refresh.
  • Like and unlike a post. The heart changes right away.
  • Open the app offline and still see the last feed.
  • Show a "New posts" button when newer posts exist.

Non-functional

  • Smooth scrolling at 60/120 fps.
  • First content on screen in under one second (from the cache).
  • Low data use. Low memory, even after 1,000 posts.
  • No duplicate or missing posts when pages change.
  • Like counts stay correct after errors.

Out of scope

  • The ranking algorithm (the server decides the order).
  • Comments screen, creating posts, ads, video autoplay.

Questions to ask the interviewer

  • Is the feed ranked or in time order? Can the order change between requests?
  • Can posts be edited or deleted after we cache them?
  • Do we need live updates, or is "pull to refresh" enough?
  • What media types? Photos only, or video too?
  • How old can cached content be before we hide it?

High-level architecture

+----------------+      +-------------------+      +-------------------+
|  FeedView      | ---> |  FeedViewModel    | ---> |  FeedRepository   |
|  List / UICV   | <--- |  @Observable      | <--- |                   |
+-------+--------+      +-------------------+      +----+---------+----+
        |                                               |         |
        | prefetch                                      v         v
        v                                     +-----------+   +-------------+
+----------------+                            |  FeedAPI  |   |  FeedStore  |
| ImagePipeline  |                            |  (cursor  |   |  (SQLite /  |
| (memory+disk)  |                            |   pages)  |   |  SwiftData) |
+----------------+                            +-----------+   +-------------+
                                                    ^
                                              +-----+-------+
                                              | ActionQueue |  likes that wait
                                              | (outbox)    |  for the network
                                              +-------------+
PartJob
FeedViewDraws rows. Tells the view model which row is on screen.
FeedViewModelHolds posts and the load state. Starts page loads. Handles likes.
FeedRepositoryDecides cache vs network. Writes every page to the store.
FeedAPITalks to the server with cursors.
FeedStoreLocal copy of the first pages. Used on launch and offline.
ImagePipelineLoads, downsamples and caches images. Prefetches the next rows.
ActionQueueSaves likes made offline and sends them later.

The main idea: show the cache first, then refresh. This pattern is called "stale-while-revalidate". The user never looks at an empty screen.

Data model

struct Post: Identifiable, Codable, Sendable, Equatable {
    let id: String
    let author: Author
    let text: String
    let media: [Media]
    let createdAt: Date
    var likeCount: Int
    var isLiked: Bool
    let commentCount: Int
}

struct Author: Codable, Sendable, Equatable {
    let id: String
    let name: String
    let avatarURL: URL?
}

struct Media: Codable, Sendable, Equatable {
    enum Kind: String, Codable, Sendable { case image, video }
    let kind: Kind
    let url: URL
    let width: Int          // known size: the row height is right before the image loads
    let height: Int
}

struct FeedPage: Decodable, Sendable {
    let posts: [Post]
    let nextCursor: String?   // nil = no more pages
}

enum LoadState: Equatable {
    case idle
    case loadingFirstPage
    case loadingNextPage
    case failed(String)
    case endReached
}

width and height matter. Without them, the row grows when the image arrives, and the list jumps.

API / interfaces

Server endpoints

GET  /v1/feed?cursor=<opaque>&limit=20   -> { "posts": [...], "nextCursor": "abc" }
GET  /v1/feed/new-count?since=<postId>    -> { "count": 7 }
PUT  /v1/posts/{id}/like                  body: { "liked": true }

Notes:

  • The cursor is opaque: the app does not read it, it just sends it back.
  • PUT with the wanted state, not "toggle". Sending liked: true twice gives the same result. This is called idempotent (safe to repeat). Retries cannot flip the like by mistake.

Swift protocols

protocol FeedAPI: Sendable {
    func fetchFeed(cursor: String?, limit: Int) async throws -> FeedPage
    func setLike(postID: String, liked: Bool) async throws
}

protocol FeedStore: Sendable {
    func cachedPosts() async -> [Post]
    func replaceAll(with posts: [Post], nextCursor: String?) async
    func append(_ posts: [Post], nextCursor: String?) async
    func updateLike(postID: String, isLiked: Bool, likeCount: Int) async
}

Deep dives

1. Pagination with cursors

Why not ?page=3 (offset)? New posts are added at the top while the user scrolls. Then every item moves down, and page 3 now starts with an item we already showed.

Before:  page 1 = [A B C]   page 2 = [D E F]
New post X arrives at the top.
After:   page 1 = [X A B]   page 2 = [C D E]   <- C is shown twice

A cursor says "give me posts after this one". New posts at the top do not change it.

The view model loads the next page when the user is 5 rows from the end. It must not start two loads at the same time.

@MainActor
@Observable
final class FeedViewModel {
    private(set) var posts: [Post] = []
    private(set) var state: LoadState = .idle
    private var nextCursor: String?
    private let api: FeedAPI
    private let store: FeedStore

    init(api: FeedAPI, store: FeedStore) {
        self.api = api
        self.store = store
    }

    func start() async {
        posts = await store.cachedPosts()        // 1. show the cache right away
        await refresh()                          // 2. then ask the server
    }

    func refresh() async {
        state = .loadingFirstPage
        do {
            let page = try await api.fetchFeed(cursor: nil, limit: 20)
            posts = page.posts
            nextCursor = page.nextCursor
            state = page.nextCursor == nil ? .endReached : .idle
            await store.replaceAll(with: page.posts, nextCursor: page.nextCursor)
        } catch {
            state = .failed("Could not refresh")   // keep showing cached posts
        }
    }

    func loadMoreIfNeeded(current post: Post) async {
        guard state == .idle, let cursor = nextCursor else { return }   // one load at a time
        guard let index = posts.firstIndex(where: { $0.id == post.id }),
              index >= posts.count - 5 else { return }

        state = .loadingNextPage
        do {
            let page = try await api.fetchFeed(cursor: cursor, limit: 20)
            let known = Set(posts.map(\.id))
            posts += page.posts.filter { !known.contains($0.id) }      // dedupe by id
            nextCursor = page.nextCursor
            state = page.nextCursor == nil ? .endReached : .idle
            await store.append(page.posts, nextCursor: page.nextCursor)
        } catch {
            state = .failed("Could not load more")    // show a "Retry" row at the bottom
        }
    }

    func retry() { if case .failed = state { state = .idle } }
}

The view:

struct FeedView: View {
    @State var viewModel: FeedViewModel

    var body: some View {
        List {
            ForEach(viewModel.posts) { post in
                PostRow(post: post) { Task { await viewModel.toggleLike(post) } }
                    .onAppear { Task { await viewModel.loadMoreIfNeeded(current: post) } }
            }
            if case .failed(let message) = viewModel.state {
                Button("Retry: \(message)") { viewModel.retry() }
            }
        }
        .refreshable { await viewModel.refresh() }
        .task { await viewModel.start() }
    }
}

Why .onAppear { Task { ... } } and not .task on the row? .task is cancelled when the row leaves the screen. If the user scrolls fast, the page request is cancelled in the middle.

2. Prefetching

Two kinds of prefetching make scrolling feel instant:

  • Next page. Start the request 5 rows before the end (above). The user rarely sees a spinner.
  • Images. Start image downloads for the rows just below the screen.

In UIKit, UICollectionViewDataSourcePrefetching tells you which rows are coming:

extension FeedViewController: UICollectionViewDataSourcePrefetching {
    func collectionView(_ collectionView: UICollectionView,
                        prefetchItemsAt indexPaths: [IndexPath]) {
        for indexPath in indexPaths {
            let post = viewModel.posts[indexPath.item]
            for media in post.media where media.kind == .image {
                imagePrefetcher.start(url: media.url, priority: .utility)
            }
        }
    }

    func collectionView(_ collectionView: UICollectionView,
                        cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
        for indexPath in indexPaths {
            viewModel.posts[indexPath.item].media.forEach { imagePrefetcher.cancel(url: $0.url) }
        }
    }
}

Rules:

  • Prefetch with low priority. Visible images always come first.
  • Do not prefetch on Low Data Mode or when ProcessInfo.processInfo.isLowPowerModeEnabled is true. Prefetch fewer rows instead.
  • Ask the server for images in the right size (?w=600). Never download the full photo for a row.

3. Optimistic likes

"Optimistic" means: update the UI first, as if the request will work. Fix it later if it fails. The heart turns red in 0 ms, not after a 500 ms network call.

extension FeedViewModel {
    func toggleLike(_ post: Post) async {
        guard let i = posts.firstIndex(where: { $0.id == post.id }) else { return }
        let before = (posts[i].isLiked, posts[i].likeCount)

        posts[i].isLiked.toggle()
        posts[i].likeCount += posts[i].isLiked ? 1 : -1
        let wanted = posts[i].isLiked
        let newCount = posts[i].likeCount     // read now: the array can change while we await

        do {
            try await api.setLike(postID: post.id, liked: wanted)
            await store.updateLike(postID: post.id, isLiked: wanted, likeCount: newCount)
        } catch {
            // Roll back, but only if the user did not tap again in the meantime.
            if let j = posts.firstIndex(where: { $0.id == post.id }), posts[j].isLiked == wanted {
                posts[j].isLiked = before.0
                posts[j].likeCount = before.1
            }
            // Show a short message: "Could not like. Try again."
        }
    }
}

Fast taps. The user taps like, unlike, like in one second. Three requests can arrive in the wrong order, and the server ends with "unliked". Two fixes:

  • Debounce (wait until taps stop): wait 300 ms after the last tap, then send only the final state.
  • One request per post at a time: if a request is running, remember the latest wanted state and send it after.

Because the endpoint is PUT liked: true/false, sending the final state is always safe.

Offline likes. If there is no network, do not roll back. Save the action to the ActionQueue (a small table: post id, wanted state, time). Send it when the network comes back.

4. Offline cache

On launch, the app shows the cached feed first, then refreshes.

What to cache:

  • The first 2–3 pages (about 50 posts). The user rarely scrolls deeper offline.
  • The nextCursor, so "load more" can continue after the cached part.
  • Images go to the image library's disk cache, not to the database.
actor SQLiteFeedStore: FeedStore {
    private let maxCachedPosts = 60

    func replaceAll(with posts: [Post], nextCursor: String?) async {
        // In one transaction: delete old rows, insert the new first page, save the cursor.
        // A crash in the middle must not leave half a feed.
    }

    func append(_ posts: [Post], nextCursor: String?) async {
        // Insert only while the total is under maxCachedPosts.
    }

    // cachedPosts() and updateLike(...) are not shown. They read and update rows.
}

Show how old the cache is. A small "Updated 2 hours ago" label is honest and cheap.

Edge cases:

  • Deleted posts. A cached post may be deleted on the server. The next refresh replaces the cache, so it goes away. If the user opens it, show "This post is no longer available".
  • Logout. Clear the feed store, the action queue and the image cache. The next user must not see them.
  • Schema change. If the Post format changes in a new app version, drop the cache. It is only a cache.

5. Memory and scrolling performance

After 1,000 posts, what uses memory?

  • Post structs are small (a few KB each). 1,000 posts are only a few MB. Keeping them is fine.
  • Images are the real cost. Downsample them to the row size, and keep them in an NSCache with a limit.
  • Rows are reused by List and UICollectionView, so views do not grow with the list.

For smooth scrolling:

  • Use stable ids (post.id). Then the diffable data source or SwiftUI only redraws rows that changed.
  • Know the row height before the image loads (from Media.width/height).
  • Do text and date formatting once, not on every redraw. Cache RelativeDateTimeFormatter output.
  • Never decode images on the main thread.

New posts. Poll GET /v1/feed/new-count every 60 seconds while the feed is on screen. Show a "7 new posts" button. Do not insert posts at the top while the user reads. The content would jump. Insert them when the user taps the button, then scroll to the top.

Trade-offs

DecisionOption AOption BUsual pick
PaginationOffset (?page=3): simpleCursor: stable when data changesCursor
Like UXWait for server: always correctOptimistic: instant, may roll backOptimistic + rollback
Like endpointPOST /togglePUT liked: BoolPUT (idempotent)
Offline cache sizeWhole feed: more offline contentFirst pages: small, simpleFirst 2–3 pages
New postsPush or socket: real timePoll count every 60 s: simplePoll, while visible
List UISwiftUI List: less codeUICollectionView: more controlEither; UIKit for very complex rows

What interviewers look for

  • Cursor pagination, and you can explain the duplicate problem with offsets.
  • A guard so only one page load runs at a time, and dedupe by id.
  • Cache first, then network. The user never sees a blank screen on launch.
  • Optimistic UI with a correct rollback, and a plan for fast repeated taps.
  • Idempotent like endpoint (PUT with state).
  • Image prefetching with low priority and cancel, plus downsampling.
  • Known media sizes so rows do not jump.
  • Clear states: loading first page, loading more, error with retry, end of feed.
  • Not inserting new posts under the user's finger.
  • Follow-ups: deleted or edited posts, logout cleanup, video in the feed, accessibility of the like button.