Mobile System Design Interview

Course Content

Mobile System Design Interview

11 sections · 23 lessons

News Feed: persistence, scroll performance and follow-ups


The architecture from the previous lesson puts the device database at the centre of the feed. This lesson takes the three questions that follow from that choice and that an interviewer will push on: what is stored and for how long, how the feed stays at 60 frames per second while it is read from that store, and the short follow-ups this problem always attracts.

Start with persistence: what is stored, in what, for how long, and how a fresh page is reconciled with what is already there. The last of those is the part candidates skip and interviewers ask about.

Reconciling a refresh with what is storedFetchnewest pageUpsertposts by idRewritefeed orderingEvictbeyond the capTwo tables: posts keyed by id, and an ordered feed table pointing at them.
Upserting by id rather than clearing the table is what keeps the user's scroll position alive.

What is stored where

DataStoreCeiling
Post rows, feed orderingDevice database~500 posts or 7 days, whichever comes first
Post images and video thumbnailsFile cache~200 MB, least-recently-used
Feed cursor, last refresh timeKey-valueBytes
Pending reactionsDatabase (outbox table)Never evicted until sent

The outbox row is the exception to every eviction rule. It represents something the user did that the server does not know about yet, and deleting it loses their action.

Retention, and why time alone is wrong

A pure time-to-live — "delete posts older than 7 days" — throws away a commuter's offline reading material overnight. A pure count keeps a stale feed forever. Use both, and evict from the bottom of the feed rather than by age of the post: the entries furthest from where the user reads are the ones they are least likely to need.

Reconciling a refresh with what is stored

Pull-to-refresh fetches page 1 with no cursor. Three cases, and the third is the interesting one.

Case 1: overlap. The returned page contains posts already stored. Upsert everything, rewrite the top of feed_entries up to the overlap point, keep everything below. The user's position is preserved because the post they were reading still exists at its position.

Case 2: identical. Nothing new. Update updated_at and show "you're up to date".

Case 3: no overlap. The user has been away long enough that page 1 shares nothing with their stored feed. There is a gap of unknown size in the middle.

Two honest options for case 3. Insert a gap marker — a row rendering as "show new posts" that fetches the intermediate pages when tapped — which preserves the user's place and is what most large feed apps do. Or replace the stored feed and return the user to the top, which is far simpler and loses their position. Say which you would pick and why: gap marker if the feed is a reading surface people return to, replace if the feed is disposable.

Offline: refresh with no network changes nothing on disk. Show the stored feed, a banner, and a retry — never an empty state, because the data is right there.

Scroll performance

This is the deep dive this problem is built for, and the one you named as the hard part in minute three. The target is 16.7 ms per frame, and every technique below exists to protect that budget.

Where the 16.7 ms goes

At 60 frames per second the system needs a finished frame every 16.7 ms. Subtract the platform's own layout and draw work and you have roughly 8–10 ms of your own time per frame. Binding one row must cost around 2–4 ms, because fast scrolling can bind several rows in a single frame.

Anything that exceeds it drops a frame, and a dropped frame during a flick is the stutter users describe as "janky".

Scroll event60–120 per secondCompute visible rangewhich items are on screen< 1 msRecycle viewsreuse, never allocate< 1 msBind datafrom an already-loaded page< 2 msLayout + drawone framethe rest of 16 msone frame — 16.6 ms at 60 fps, 8.3 ms at 120 fpswhat blows the budgetdecoding a full-size image on the main threada database query during bindmeasuring text you could have measured oncewhat buys it backprefetch the next page before the user reaches itdecode and downsample off the main threadhold a stable item id so diffing is cheap
Everything in the pipeline shares one 16 ms budget — which is why a single synchronous read shows up as a visible stutter.

The six techniques

1. Recycle views. Both platforms' list components create views only for what is visible plus a small buffer, and reuse them as rows leave. A list that builds a view per item holds ten thousand view hierarchies in memory and dies.

2. Move work off the bind path. Binding should be assignment, not computation. Formatting a relative timestamp ("3 h ago") costs a fraction of a millisecond, and eight of them per frame plus text measurement adds up fast. Compute display strings when the post is written to the database and store them as columns.

3. Do not touch the network or JSON on the main thread. Parsing a 20 KB response takes several milliseconds. It happens on a background thread, into the database, and the row reads a parsed column.

4. Downsample images at decode time. Request the 480 px variant for a 400 px-wide row on a low-end device and decode to the target size, so the bitmap costs ~0.9 MB rather than 4.4 MB. Eight rows becomes 7 MB instead of 35 MB.

5. Prefetch the next page early. Trigger loadMore() when the user is within about six items of the end — roughly a screen. A page fetch is 150–400 ms; a fast scroll crosses six rows in about that time, so the page lands as the user arrives.

6. Cancel image loads on recycle. When a row is reused, cancel its outstanding image request and clear its image. Without cancellation, a fast scroll leaves dozens of live requests competing for the radio, and a late response paints the wrong photo into a recycled row.

Offline: scrolling is unaffected — rows come from the database. Images not in the file cache show their inline blurred placeholder permanently rather than a broken-image icon.

Follow-ups

With the deep dive done, expect the questions this problem always attracts. Have a short, specific answer to each; none needs more than ninety seconds.

An optimistic reaction, with the rollbackUser taps likeWrite local,count movesSend to serverFailurereverts the rowThe UI reads the local row, so both the apply and the revert are single writes.
Optimism is only honest if the rollback path is designed at the same time as the happy path.

Optimistic reactions with rollback

Tapping a reaction writes my_reaction and an incremented count to the database immediately and enqueues an outbox entry. The heart fills in under 16 ms instead of after a 400 ms round trip.

The rollback is the half that gets skipped. If the server permanently rejects the change — the post was deleted, or the account is restricted — revert the row to its previous value and show a brief, non-blocking message. Reverting silently is worse than never being optimistic, because the user watched it succeed. Store the previous value in the outbox row so the revert does not need a network call to find it.

Deep links into a post

A link opens the app directly on a post that may not be in the feed. The detail screen must therefore be able to fetch a single post by identifier and write it into the same posts table, so the feed and the detail screen stay consistent. Build a synthetic back stack — tapping back should reach the feed, not exit the app.

Restoring scroll position after process death

Do not store a pixel offset. Row heights change with font size, and a restored pixel offset lands somewhere arbitrary. Store the identifier of the first visible post plus its offset within that row, and on relaunch scroll to that post. If it has been evicted, scroll to the nearest surviving entry above it.

Video autoplay and its cost

Autoplay is the most expensive feature in a feed: it holds a hardware decoder, keeps the radio streaming continuously, and prevents the screen from dimming. The defensible design is autoplay muted on unmetered connections only, one video at a time — the most visible one — paused the moment it scrolls out, with a user setting that includes "never".

Accessibility for a feed list

Each row is one accessibility element with a composed label ("Ravi Menon, 3 hours ago, photo, 214 reactions"), not five separate ones the user has to swipe through. Actions — react, open comments — are exposed as named custom actions. Support large text sizes, which means row heights vary and any fixed-height assumption in the scroll-performance work above must be a cached measured height rather than a constant. Honour the reduce-motion setting for autoplay and animated transitions.