Design the client of a social feed: cursor paging over a re-ranked list, de-duplication, a “new posts” pill and keeping the scroll position still.

26.5.1 · L5 · Interview

Reported at: LinkedIn

Predict first

Page two repeats two items and new posts are inserted above the reading position. What happens?

struct Post { let id: String; let h: Int }
var feed = [Post(id: "a", h: 100), Post(id: "b", h: 120), Post(id: "c", h: 80), Post(id: "d", h: 90)]
// page 2 comes from a re-ranked server list: it repeats c and d and adds e, f
func append(_ page: [Post]) {
    let seen = Set(feed.map(\.id))
    let fresh = page.filter { !seen.contains($0.id) }
    feed += fresh
    print("appended", fresh.map(\.id), "dropped duplicates", page.count - fresh.count)
}
append([Post(id: "d", h: 90), Post(id: "e", h: 70), Post(id: "c", h: 80), Post(id: "f", h: 60)])
print("feed:", feed.map(\.id).joined())
// two new posts arrive at the head while the user has scrolled to offset 150 (inside post b)
var offset = 150
let incoming = [Post(id: "n2", h: 110), Post(id: "n1", h: 95)]
print("naive offset after insert:", offset, "-> now looks at", { () -> String in
    let all = incoming + feed; var y = 0
    for p in all { if offset < y + p.h { return p.id }; y += p.h }; return "end" }())
offset += incoming.map(\.h).reduce(0, +)
feed = incoming + feed
var y = 0; var at = ""
for p in feed { if offset < y + p.h { at = p.id; break }; y += p.h }
print("anchored offset:", offset, "-> still looks at", at)
  • The duplicates are dropped (e and f are appended), a naive insert moves the user to n1, and adding the inserted height (offset 355) keeps them on b
  • Both pages are appended in full and the offset stays correct
  • The user stays on b with the offset unchanged

Mental model. A ranked feed is a river, not a list. You cannot count on the same stone being in the same place twice, so you remember which stone you were standing on, not how far along the bank you were.

The server decides the order, and it can change between requests, so the contract matters more than the UI. Page with an opaque cursor that the server ties to a ranking snapshot (Q318), and treat every page as possibly overlapping the previous one: when the ranking is recomputed, an item can appear on page one and again on page two. The client therefore de-duplicates by stable id when it appends, keeping the first occurrence so rows already on screen do not move. In the check the second page repeats d and c and adds e and f, and the merge appends only the two new items. Keep posts in the local database keyed by id with a sort key, and let the screen observe it (Q331, Q337), so a refresh is an upsert and an offline launch shows the last feed at once. Edits and deletions arrive as new versions or tombstones; the list is never trusted to be complete.

New posts are a scroll-position problem. If you insert them above the rows the user is reading, everything jumps. Either buffer them behind a “N new posts” pill and merge on tap, or insert them and anchor the offset: add the height of the inserted rows to the content offset, or restore a saved (anchor id, offset within row) pair. The check shows the difference: with an offset of 150 the naive insert makes the user suddenly look at n1, while adding the 205 points of inserted rows (offset 355) keeps them on b. In UIKit that means diffable data sources with a captured anchor; in SwiftUI use an id-based scroll position (iOS 17+) rather than indices. Also design impressions (log a post as seen only after it is mostly visible for a short time, batched and sampled, Q496), image prefetching that follows the scroll direction (Q327), a gap marker when a refresh returns a window that does not touch the cached one, and a bounded cache so the feed database does not grow forever. Row heights should be measured once and cached, because estimating them wrongly is what makes anchoring drift.

Merge by id, and anchor the offset by the height of what is inserted above.

Common traps

  • Appending pages without de-duplicating. A re-ranked list repeats items and the same post appears twice.
  • Inserting at the top and keeping the numeric offset. The content moves under the user’s thumb; anchor to an item or buffer the new posts.

They might follow up with

  • Page numbers or cursors? Cursors, because insertions between requests shift page boundaries (Q318).
  • What if the user pulls to refresh far down? Replace the head and mark a gap, or drop the old tail, rather than splicing two unrelated windows.

Related questions

  • 12.3.1 How do you decode and paginate an API response, and which pagination style is safest?
  • 26.2.2 How do you set payload budgets and reduce bytes: field selection, deltas, compression, image sizes, and what…
  • 12.7.1 How do you design an image loading pipeline: decoding, downsampling, caching and de-duplication?
  • 13.2.2 How do you use SQLite safely and quickly: bound parameters, indexes, and schema migrations?
  • 13.5.1 What is the repository pattern, and why map storage models to domain models?
  • 26.2.3 How do you design analytics and experimentation that respect privacy: event schemas, consent, minimisation…

Design the client side of a video player: startup, adaptive bitrate… →