Design an Infinite-Scroll News Feed
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
+-------------+| Part | Job |
|---|---|
FeedView | Draws rows. Tells the view model which row is on screen. |
FeedViewModel | Holds posts and the load state. Starts page loads. Handles likes. |
FeedRepository | Decides cache vs network. Writes every page to the store. |
FeedAPI | Talks to the server with cursors. |
FeedStore | Local copy of the first pages. Used on launch and offline. |
ImagePipeline | Loads, downsamples and caches images. Prefetches the next rows. |
ActionQueue | Saves 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.
PUTwith the wanted state, not "toggle". Sendingliked: truetwice 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 twiceA 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.isLowPowerModeEnabledis 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
Postformat 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?
Poststructs 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
NSCachewith a limit. - Rows are reused by
ListandUICollectionView, 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
RelativeDateTimeFormatteroutput. - 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
| Decision | Option A | Option B | Usual pick |
|---|---|---|---|
| Pagination | Offset (?page=3): simple | Cursor: stable when data changes | Cursor |
| Like UX | Wait for server: always correct | Optimistic: instant, may roll back | Optimistic + rollback |
| Like endpoint | POST /toggle | PUT liked: Bool | PUT (idempotent) |
| Offline cache size | Whole feed: more offline content | First pages: small, simple | First 2–3 pages |
| New posts | Push or socket: real time | Poll count every 60 s: simple | Poll, while visible |
| List UI | SwiftUI List: less code | UICollectionView: more control | Either; 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 (
PUTwith 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.