Design a Mobile Paging Library
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
In this loop the "system" is mostly a library that runs on the phone, plus the paging API behind it. That makes API and library design as important as infrastructure: what the server promises about order and cursors decides whether the phone can ever get the list right. How the phone keeps a local database, syncs changes and caches images is the subject of the offline-first news feed loop; how a server builds and ranks a feed is the news feed loop. We link to both instead of teaching them again.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | A marketplace app's "new listings" list with infinite scroll | A social feed: new posts arrive on top, older ones below, and it works offline | One paging library for 20 teams, on iOS, Android and web, for ranked and chronological lists |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | 1M DAU; 10M page requests/day (≈ 116/s, ≈ 347/s at peak) | 50M DAU; 600M page requests/day (≈ 6,944/s, ≈ 20.8K/s at peak) plus head checks | 80M DAU; 2.4B list-page loads/day across all teams (≈ 83K/s at peak), 960M of them on our feed backend |
| On the device | View recycling; no network or parsing on the main thread | ≤ 200 items in memory, < 30 MB of paging memory on low-end phones; a SQLite cache; 8.33 ms frames at 120 Hz | Memory budgets per device class; one behaviour on three platforms |
| Targets | No duplicates, no gaps, no spinner at the bottom in normal scrolling | No dropped frames on flings; keyset seek < 5 ms P99; refresh without jumps | A stable public API; a bad library release contained in hours |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a Paging Library?
You Already Use One: a Conveyor Belt That Only Loads What's Near You
Picture a sushi conveyor belt in a restaurant with a million dishes in the kitchen. The belt in front of you holds only a few dozen plates. As you eat your way along, the kitchen puts new plates on just ahead of you, and plates far behind you go back to the kitchen. It feels endless, but the table never holds more than a few dozen plates.
A paging library does the same for a list on a phone. The list looks endless, but the app keeps only a small window of items in memory, fetches the next page (a fixed-size batch, say 20 items) just before you need it, and drops items far away from what you're looking at.
| You do | The library does |
|---|---|
| Open a feed | Loads the first page, shows it, and quietly fetches the next one |
| Scroll steadily | Fetches the next page when you're a set number of items from the end, so you never see a spinner |
| Fling hard | Looks further ahead, and cancels work for rows that flew past without being seen |
| Scroll back up an hour later | Drops pages you left far behind, and reloads them from the phone's own database when you come back |
| Pull to refresh | Fetches the newest items and merges them in without the list jumping under your thumb |
What Makes It Hard
- People fling fast. A hard fling on a phone moves thousands of pixels a second. A fetch has to start early enough to finish before the user gets there.
- The list changes underneath. New items arrive at the top while you read, and old ones get deleted. A naive "page 3" then repeats or skips items.
- Phones have small memory and tight frame budgets. A cheap phone has little RAM to spare, and at 120 Hz the app has 8.33 ms to produce each frame.
- A page must never skip or repeat. A repeated item looks like a bug, and on some UI toolkits a repeated ID crashes the app. A skipped item is silently lost.
The Question the Whole Loop Answers
How do we make an endless list feel instant and stable while holding only a little of it?
The answer gets sharper every round:
- Round 1: the server pages by position in a total order (a cursor), never by count, and the phone only builds views for what's on screen.
- Round 2: the phone's database is the single source of truth: the network fills it, the list reads from it, a bounded window lives in memory, and every change reaches the screen as a diff computed off the main thread.
- Round 3: the paging logic becomes a product with a contract: pluggable data sources, ranked-feed sessions, in-place updates, accessibility, telemetry and a release process that can't break 20 teams.
Round 1 · Mid-level · "An Infinite List That Doesn't Skip or Repeat"
~35 min · SDE II (L5) · 1 region, 3 AZs · 1M DAU · ~347 page requests/s peak · mid-range phones
R1.1 Establish Design Scope
The interviewer says: "Our marketplace app has a 'New listings' screen. It shows a page of 20 and a 'Load more' button, and users hate it. Make it an infinite list." We ask before we draw.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| What's the sort order? | Newest first. | We need a sort key that says "newest", and a way to break ties (step 1.1). |
| How big is a page? | 20 listings. | Sets the request size and how far ahead we prefetch (step 1.4). |
| Can listings be added while someone scrolls? | Yes, sellers post all the time. Some get sold and removed. | Positions shift under the reader, so "page 3" is not a stable idea (step 1.1). |
| Must it work offline? | Not yet. | No local database this round; Round 2 adds it. |
| How many listings in total? | Millions, and growing. | Deep pages must stay as fast as the first page (step 1.2). |
| Which devices? | Mid-range iOS and Android phones. | Smooth scrolling at 60 Hz is the bar this round; 120 Hz comes later. |
| How many users? | About 1M daily users, around 10 pages each a day. | We derive traffic in R1.7. |
Out of scope for this round: new items appearing at the top while reading, offline use, memory limits for very long sessions, and velocity-aware prefetch.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, most of it comes back.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Requirement |
|---|---|
| "Infinite list" | Load the first page on open; load the next page automatically as the user nears the end |
| "Users hate the button" | No visible wait in normal scrolling: the next page is usually there before the user is |
| "Listings are added and removed" | Scrolling down never shows a listing twice and never skips one that existed when the user started |
| (implied) | Show loading and error states at the bottom of the list, with a retry |
Not yet: loading newer items at the top (prepend), offline reading, and prefetch that adapts to scroll speed.
R1.3 Non-Functional Requirements: the Questions
Numbers come in R1.7. For now, the questions and why each matters:
- Smoothness. A 60 Hz display draws a frame every ms; a 120 Hz display every ms. The main thread (the one thread that draws the UI and handles touches) must finish its share of each frame inside that budget, or the user sees a stutter, called jank. Where may network calls, JSON parsing and view creation run?
- Correctness. What exactly does "no duplicates, no gaps" mean when the list is changing? We'll define it as: every listing that existed for the whole scroll appears exactly once. Listings posted after the user started scrolling appear on the next refresh, not in the middle of their scroll.
- Memory. How many views and items may exist at once? A phone doesn't give an app much, and the OS kills apps that use too much.
- Data use. Many users pay per gigabyte. How many bytes does a page cost, and how many pages do we fetch that nobody sees?
- Server cost. Does a request for page 5,000 cost the same as page 1?
R1.4 The API
One endpoint. It returns a page of items and a cursor: a token that means "continue after this item".
First page:
httpGET /v1/items?limit=20 HTTP/1.1 Host: api.example-market.com Authorization: Bearer <token> Accept-Encoding: gzip
httpHTTP/1.1 200 OK Content-Type: application/json Content-Encoding: gzip { "items": [ { "item_id": "731829301994475520", "title": "Road bike, 54 cm frame", "price": { "amount_cents": 42000, "currency": "USD" }, "seller": { "id": "90412", "name": "Ana's Bikes" }, "thumb": { "id": "tq81", "w": 720, "h": 720 }, "created_at_us": 1790596800123456 } ], "next_cursor": "eyJ2IjoxLCJsIjoibmV3IiwidHMiOjE3OTA1OTY3OTc0MTAyMDEsImlkIjoiNzMxODI5MjAyMTE4MTA2NDk2In0.pG7vQ2", "has_more": true }
Next page: send the cursor back exactly as received.
httpGET /v1/items?limit=20&cursor=eyJ2IjoxLCJsIjoibmV3IiwidHMiOjE3OTA1OTY3OTc0MTAyMDEsImlkIjoiNzMxODI5MjAyMTE4MTA2NDk2In0.pG7vQ2 HTTP/1.1 Host: api.example-market.com Authorization: Bearer <token>
What's inside the cursor. It is base64url(JSON) + "." + signature. Decoded, the JSON part is:
json{ "v": 1, "l": "new", "ts": 1790596797410201, "id": "731829202118106496" }
tsandidare the sort key of the last item on the page (its creation time in microseconds, and its ID). The next page means "items that sort after this pair".vis a format version andlnames the list, so one cursor can't be replayed against another list.- The signature is an HMAC (a keyed hash only the server can produce) over the JSON. It is a signature, not encryption: anyone can base64-decode the cursor, so it must hold nothing secret.
Why opaque? The client stores the cursor and sends it back; it never builds, reads or edits one.
- We can change what's inside (add a field, change the sort) without breaking app versions already on phones. Old cursors are recognized by
v. - Clients can't craft cursors to scan the table, jump to arbitrary positions, or reach a list they shouldn't. A bad signature gets
400. - The order stays the server's business. If the client built "after (ts, id)" itself, the sort rule would be frozen into every app version.
Two precision details that matter:
tsis in microseconds, the precision PostgreSQL'stimestamptzstores. A cursor rounded to milliseconds would compare against a value that doesn't exist, and listings created in the same millisecond just after the rounded value would be skipped.- IDs are strings in JSON. Our IDs are 64-bit numbers; JavaScript and many JSON parsers hold integers exactly only up to , so a 64-bit ID sent as a JSON number can be silently rounded.
tsin microseconds is about , below , so it is safe as a number.
| Status | When | What the app does |
|---|---|---|
200 OK | A page | Append the items; store next_cursor; has_more: false means the end |
400 Bad Request | The cursor fails its signature check or has an unknown version | Drop the cursor and reload from the top |
401 Unauthorized | The access token expired | Refresh the token once, then repeat the same request |
429 / 503 | Overload | Show the retry row at the bottom; retry with backoff, honouring Retry-After |
Recap
- One call:
GET /v1/items?cursor=&limit=20, returning items,next_cursorandhas_more. - The cursor is opaque and signed; decoded, it is the last item's
(created_at, item_id). - The server never counts rows to find a page.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From "Page Numbers" to Keyset Cursors and Recycled Views
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is usually the next problem.
Step 1.0: The Baseline
The app asks for ?page=5. The server turns that into an offset: skip the first 80 rows, return the next 20. The app keeps every loaded item in a list and creates a view for each.
sqlSELECT item_id, title, price_cents, created_at FROM listing WHERE status = 'active' ORDER BY created_at DESC LIMIT 20 OFFSET 80; -- page 5
Synthesizing vector architecture diagram...
The page number becomes a row count. Notice that nothing in the request says which listing the user saw last.
What's good about it: it is simple, and "jump to page 50" is easy.
What it costs us: an offset is a count of rows, and the count changes whenever a row is added or removed above it. The database also has to walk past every skipped row.
Step 1.1: Page 2 Repeats Items From Page 1
The problem: a user loads page 1 (listings 1–20). While they look at it, 3 new listings are posted. They scroll, and page 2 starts with listings they already saw. Other users report the opposite: after some listings were sold and removed, a few listings never appeared at all. What would you do?
Primitives: Database Isolation Levels & Concurrency Anomalies (each page is a separate query with its own snapshot, which is why offsets drift) · Distributed Unique ID Generators (the unique tie-breaker)
Step 1.2: Deep Pages Are Slow
The problem: with offsets, page 1 answered in 2 ms, but a power user on page 5,000 waits seconds, and the database CPU climbs when bots crawl deep pages. What would you do?
Step 1.3: Scrolling a Long List Uses Lots of Memory
The problem: the list now loads smoothly from the server, but after scrolling a few hundred listings the app stutters and some phones kill it. A profiler shows thousands of row views alive at once. What would you do?
Step 1.4: The Next Page Loads Too Late
The problem: users now reach the last loaded item, see a spinner for half a second, then the next page appears. It happens every 20 items. What would you do?
Step 1.5: Parsing Freezes the UI
The problem: prefetch works, but every time a page arrives the list hitches for a frame or two, worse on older phones. What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | ?page=N → OFFSET | Repeats and gaps; slow deep pages |
| 1.1 | Page 2 repeats page 1 | Keyset cursor on (created_at, item_id), signed and opaque | No jump to page N |
| 1.2 | Deep pages are slow | Composite B-tree index; seek at any depth | One index per sort order |
| 1.3 | Memory grows with the list | View recycling; cheap bind; stable IDs | Binding logic |
| 1.4 | Spinner at the bottom | Prefetch 20 items (one page) ahead; one request per cursor | Up to one wasted page per visit |
| 1.5 | Parsing freezes the UI | Decode and prepare off the main thread; insert, don't reload | Threading discipline |
Two costs stay open for Round 2: loaded item data still grows without limit, and nothing works offline.
R1.6 Architecture v1
Now the pieces get platform and AWS names.
Synthesizing vector architecture diagram...
Follow a scroll: the list tells the controller "I'm near the end", the controller asks the network data source for the page after the stored cursor, and the answer comes back ready to insert. On the server, every page is one keyset query on an Aurora reader.
The pieces:
- Paging controller (on the phone): holds the loaded items, the next cursor and the load state (idle, loading, error, end). It decides when to fetch and makes sure only one fetch per cursor is in flight.
- Network data source: turns "load after cursor X" into an HTTP call, decodes on a background thread, and returns items plus the next cursor.
- Items API on ECS (Fargate) behind an Application Load Balancer in three AZs. It checks the cursor's signature, runs the keyset query, builds the next cursor from the 20th row, and uses the 21st row only to set
has_more. - Aurora PostgreSQL: a writer (sellers' inserts and updates) and a reader in another AZ for the list queries. Aurora readers share the cluster's storage volume, so a reader sees committed writes after a short replica lag, typically well under a second.
The server schema:
sqlCREATE TABLE listing ( item_id BIGINT PRIMARY KEY, -- 64-bit, time-ordered ID created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), -- database clock, microseconds title VARCHAR(200) NOT NULL, price_cents BIGINT NOT NULL, currency CHAR(3) NOT NULL, seller_id BIGINT NOT NULL, thumb_id VARCHAR(32), status VARCHAR(16) NOT NULL DEFAULT 'active' -- active, sold, removed ); CREATE INDEX listing_newest ON listing (created_at DESC, item_id DESC) WHERE status = 'active';
The phone's state (in memory this round):
| Field | Meaning |
|---|---|
items | Loaded listings, in order |
next_cursor | Opaque string from the last page; empty before the first load |
append_state | idle, loading, error, or end |
request_tag | Increments per request; a result whose tag isn't current is ignored |
Trace 1: the first page.
Synthesizing vector architecture diagram...
The 21st row is never shown; it only proves there is more.
Trace 2: the next page, prefetched.
Synthesizing vector architecture diagram...
Trace 3: an error and a retry.
Synthesizing vector architecture diagram...
A retry repeats the same cursor, so it can't skip or repeat anything: a page request is a read, and reading twice is harmless.
R1.7 Numbers
Traffic (assumptions: 10 page requests per user per day; peak is 3× the average)
| Quantity | Math | Value |
|---|---|---|
| Page requests per day | 1M × 10 | 10M |
| Requests/s, average | 10,000,000 ÷ 86,400 | ≈ 116 |
| Requests/s, peak | 115.7 × 3 | ≈ 347 |
Payload (assumptions: about 1 KB of JSON per listing with its seller and thumbnail metadata, so 20 KB per page before compression; gzip shrinks repetitive JSON to about 40%; thumbnails come from a CDN and are counted separately)
| Quantity | Math | Value |
|---|---|---|
| Page on the wire | 20 KB × 0.4 | ≈ 8 KB |
| Egress per day | 10M × 8 KB | 80 GB |
| Egress per month | 80 GB × 30.4 | ≈ 2.43 TB |
| Egress at peak | 347 × 8 KB ≈ 2.8 MB/s | ≈ 22 Mbps |
| Per user per day | 10 × 8 KB | 80 KB |
Database (assumptions: 20M active listings of about 2 KB each; a keyset seek costs well under 1 ms of CPU when the index and hot rows are in memory)
| Quantity | Math | Value |
|---|---|---|
| Table size | 20M × 2 KB | ≈ 40 GB |
listing_newest index | 20M × ~32 B per entry, plus page overhead | ≈ 0.7–1 GB, fits in memory |
| B-tree depth | ≈ 3 levels | |
| Peak query rate | one query per page request | ≈ 347/s |
A db.r7g.large (2 vCPUs, 16 GiB) reader holds the whole index in memory and runs a few hundred simple indexed queries a second with most of its CPU idle.
Fleet (assumption, to confirm by load test: one 1 vCPU / 2 GB task serves about 350 requests a second)
Peak need is task. We run one per AZ, 3 in all, so losing an AZ leaves 2.
Monthly cost (us-east-1 on-demand list prices, rounded; other regions list higher)
| Item | Math | Monthly |
|---|---|---|
Aurora PostgreSQL, writer + reader db.r7g.large, Aurora Standard | 2 × $0.276/h × 730 h | ≈ $403 |
| Aurora storage and I/O | 40 GB × $0.10 + reads mostly from memory | ≈ $25 |
| ECS on Fargate (Graviton), 3 × (1 vCPU, 2 GB) | 3 × ($0.03238 + 2 × $0.00356)/h × 730 h | ≈ $87 |
| ALB | processed bytes dominate: ≈ 2.6 TB a month (responses plus ~0.5 KB requests) = 2,600 LCU-hours × $0.008, + $0.0225 × 730 | ≈ $37 |
| Data transfer out | 2.43 TB × $0.09/GB (first 10 TB tier) | ≈ $219 |
| Cross-AZ, reader to tasks | ~12 KB of rows per page: 10M × 12 KB × 30.4 ≈ 3.65 TB; two of three tasks sit in other AZs: × 2/3 × $0.02/GB | ≈ $49 |
| CloudWatch, misc. | ≈ $100 | |
| Total | ≈ $0.9K/month |
Say the headline: at this scale the database instances are almost half the bill, and a keyset query costs the same at page 1 and page 5,000. With offsets, a crawler walking to page 5,000 would make each request scan 100,000 index entries, and we'd need a far bigger database for the same traffic.
R1.8 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Offset vs keyset | Keyset on (created_at, item_id) | See the table below |
| Page size | 20 | Bigger pages mean fewer requests and fewer spinners, but more bytes wasted when the user stops early and a longer first load. Smaller pages mean more requests and more chances to show a spinner. 20 fills nearly three screens of 300 px rows (about 7 rows each). |
| Prefetch distance | 20 items | Further ahead wastes more data when users stop; closer risks a visible spinner on flings. |
| Signed cursor vs plain parameters | Signed, opaque | A key to rotate and a format to version; in return we can change the cursor without an app release. |
Offset (LIMIT 20 OFFSET n) | Keyset (WHERE (ts, id) < cursor) | |
|---|---|---|
| Cost of a deep page | Grows with : walks and discards rows | seek, same at any depth |
| Inserts above the reader | Shift every later page: repeats | Land above the cursor: no effect |
| Deletes above the reader | Shift the other way: gaps | No effect; the cursor compares values |
| Jump to page N | Easy | Not possible; jump to a value (a date) instead |
| Total count and page numbers | Natural | Not provided (counting millions of rows per request is expensive and stale at once) |
| Where it's fine | Small, static lists (a settings screen, an admin table) | Anything large or changing |
R1.9 Failure Modes
| Failure | What the user sees | How the design responds |
|---|---|---|
| Network error mid-scroll | The bottom row turns into "Couldn't load. Tap to retry." | One automatic retry after about 1 s with jitter, then the retry row. Loaded items stay; the retry repeats the same cursor. When the OS reports the network is back, we retry automatically. |
| Slow backend | Longer spinner at the bottom | The client gives up after 10 s per attempt. The server stops sooner: the service has a 3 s deadline and the query a 1 s statement_timeout, so an abandoned request can't keep running on the server after the client has retried. |
| Listings removed between pages | Nothing | Keyset compares values, so the next page is still correct. A removed listing already on screen stays until the next refresh; tapping it shows "no longer available". |
Cursor rejected (400, key rotated or format retired) | The list reloads from the top | We accept the previous signing key for a day after a rotation, so this is rare. |
Expired access token (401) | Nothing | Refresh the token once and repeat the same request. If a prefetch and an image call both get 401, only one token refresh runs (single-flight); the others wait for it. |
| Reader lost with an AZ | A few seconds of errors, then normal | Aurora's cluster reader endpoint moves to a surviving instance; if the writer is the only one left, it serves reads too. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Retries repeat the same cursor, so they're safe; client timeout longer than the server's deadline; three AZs and an Aurora reader in another AZ REL 5 · REL 10 |
| Performance Efficiency | Keyset index seek at any depth; view recycling; no network or parsing on the main thread; prefetch sized from fetch time and scroll speed PERF 3 · PERF 1 |
| Security | Signed cursors can't be crafted or moved to another list; tokens on every call; TLS SEC 9 |
| Cost Optimization | About $0.9K a month; keyset keeps the database small no matter how deep users or crawlers go COST 6 |
| Operational Excellence | Skipped this round. |
| Sustainability | Only one page prefetched ahead; gzip; thumbnails sized for the screen SUS 3 |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Explains, with an example, why offsets repeat and skip items when rows are inserted or deleted.
- Designs a keyset cursor with a unique tie-breaker, writes the query and the matching index, and knows the seek is at any depth.
- Makes the cursor opaque and signed, and can say why.
- Uses view recycling and keeps bind cheap.
- Sizes the prefetch distance from fetch time and scroll speed, and keeps only one request per cursor in flight.
- Keeps network, decode and formatting off the main thread and knows the 16.7 ms and 8.33 ms budgets.
Follow-up questions
-
"Product wants a 'page 3 of 50' indicator. What do you say?" Answer: a total count means counting millions of active rows on every request, and it's wrong a second later as listings come and go. For a changing list we show "Showing newest listings" and, if they need orientation, a date divider. If product truly needs counts, an approximate count maintained by a background job is fine for display, but pages still use cursors.
-
"Why not use
item_idalone as the cursor? It's time-ordered." Answer: we could, ifitem_idis generated in time order and the list is always "newest first". Thenitem_id < cursoris a total order on its own. We kept(created_at, item_id)because the same pattern works when the sort column isn't the ID (a "recently updated" list, or a price sort, where the ID is only the tie-breaker). Either way, the rule is the same: the cursor must hold every column of a total order. -
"The user sorts by price. What changes?" Answer: a new index
(price_cents, item_id)and a cursor holding the last row's price and ID. Price can change while someone scrolls; a listing whose price drops below the cursor after they passed it won't be seen, and one whose price rises past it may be seen twice. For a marketplace that's acceptable, and the client drops a repeateditem_id. Round 3 comes back to sort keys that move.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Bigger pages fix the repeats" | Any insert above the reader still shifts every offset. |
"created_at < cursor is enough" | Rows sharing a timestamp are skipped. Add the unique ID as a tie-breaker. |
| "Put milliseconds in the cursor" | The column stores microseconds; a rounded cursor skips rows. |
| "Load more when the user hits the end" | The fetch takes hundreds of ms; start at the prefetch distance. |
| "Parse on the main thread, it's small" | It shares a 16.7 ms frame with scrolling, and slow phones are several times slower. |
Round 2 · Senior · "Both Directions, Offline, and 120 Hz Smooth"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 50M DAU · ~20.8K page requests/s and ~8.7K head checks/s at peak · keyset seek < 5 ms P99 · < 30 MB of paging memory on low-end phones · 8.33 ms frames
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We turned a 'Load more' button into an infinite list for 1M daily users, about 347 page requests a second at peak. Offsets repeat and skip items when rows are added or removed, and they get slower with depth, so we page with a keyset cursor: the last item's
(created_at, item_id), where the unique ID breaks ties, signed and opaque to the client. The server seeks a composite B-tree index,WHERE (created_at, item_id) < cursor ORDER BY created_at DESC, item_id DESC LIMIT 21, which costs the same at any depth; the 21st row only setshas_more. On the phone we use a recycling list, keep bind cheap, prefetch one page (20 items) ahead, allow one request per cursor, and do network and decoding off the main thread. About $0.9K a month. Two costs are open: loaded items grow without limit, and nothing works offline."
Architecture v1, compact
Synthesizing vector architecture diagram...
Round 1 in one picture: the list asks, the controller fetches the page after the cursor, and the server seeks an index.
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Page 2 repeats page 1 | Keyset cursor with a tie-breaker, signed | No jump to page N |
| 1.2 | Deep pages are slow | Composite index; seek | An index per sort order |
| 1.3 | Memory grows with views | View recycling; stable IDs | Binding logic |
| 1.4 | Spinner at the bottom | Prefetch one page ahead; one request per cursor | Some wasted pages |
| 1.5 | Parsing freezes the UI | All work off the main thread | Threading discipline |
Open costs: unbounded item memory; no offline use.
R2.1 The Scope Raise
Interviewer: "We're now a social app with 50 million daily users, and the list is a feed of posts in topics people follow. New posts appear at the top while people read further down. Pull-to-refresh must not throw them back to the top or make the list jump. The feed must open and scroll offline from what we saved. People fling at thousands of pixels a second on 120 Hz phones, and our low-end Android phones must stay under about 30 MB for the whole list, even after someone scrolls through 10,000 posts."
A scope raise is not the end of scoping. We ask back, and say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Is a topic feed the same for everyone who opens it? | Yes: chronological, newest first, the same posts for every viewer. Per-user state like "I liked this" comes from elsewhere. | Pages carry no per-user fields, so a topic's head page can be cached and shared (R2.5). |
| How much traffic? | 50M DAU, about 12 pages each a day. | 600M page requests a day (R2.6). |
| Should new posts appear on their own while someone reads? | No. Show a "New posts" pill; insert them when the user taps it or pulls to refresh. | A cheap way to learn that the head changed (a head check), and anchor-preserving inserts (step 2.3). |
| Offline: how much? | Open the last feeds and scroll what was loaded, with images. | The phone's database becomes the list's source (step 2.1). Images reuse the offline feed loop's disk cache. |
| Which phones set the budget? | Low-end Android with 2–3 GB of RAM, and 120 Hz flagships. | < 30 MB for paging on the low end is a budget we assume for that device class; 8.33 ms frames on the high end. |
| How long is a long session? | Some people scroll 10,000 posts. | A bounded window in memory, and pages dropped and reloaded (step 2.2). |
| What's the backend target? | Keyset seek under 5 ms at P99, and about 20K requests a second at peak. | Aurora readers sized for the peak with an AZ lost (R2.6). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Users | 1M DAU | 50M DAU |
| Requests | 10M pages/day, 347/s at peak | 600M pages/day, ≈ 20.8K/s at peak; plus ≈ 8.7K head checks/s |
| Directions | Append only | Append, prepend and refresh |
| Offline | None | Scroll saved feeds with no network |
| Memory | Unbounded item data | ≤ 200 items in memory; < 30 MB of paging memory on low-end phones |
| Frames | 60 Hz | 120 Hz: 8.33 ms per frame, including fast flings |
| Payload | ~8 KB per page | ≤ 25 KB per 20-post page |
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| The list binds to network results | Offline, there's nothing to show. Online, each response replaces what's on screen, so rows flicker as pages arrive. |
| Every loaded item stays in memory | 10,000 posts at ~2 KB each is 20 MB of objects before a single image, on a phone whose whole paging budget is 30 MB. |
| Only "load more" at the bottom | New posts at the top can't be loaded without reloading the whole list, and inserting them naively pushes the post the user is reading down the screen. |
| One request tag, no states per direction | A refresh and an append running together can both write, and a late append can glue old posts onto a refreshed list. |
| Prefetch triggered by each bind near the end | A fling binds dozens of rows in a second; without coalescing that's a burst of duplicate requests. |
| Inserting pages and reloading the list | Recomputing which rows changed on the main thread costs milliseconds per update; at 120 Hz we have 8.33 ms for everything. |
R2.3 New Requirements and API Additions
1. Paging in both directions. Each list is addressed by ID. A request without a cursor returns the head (the newest page). A cursor plus a direction walks older or newer.
httpGET /v1/lists/40117/items?limit=20 HTTP/1.1 Host: api.example-social.com Authorization: Bearer <token> Accept-Encoding: gzip
httpHTTP/1.1 200 OK Content-Type: application/json Content-Encoding: gzip ETag: "hv-7Qm2" { "items": [ { "item_id": "731902214477398016", "sort_ts_us": 1790596790042113, "version": 3, "author": { "id": "5520", "name": "Kim Lee" }, "text": "Trail report: the north loop is open again...", "thumb": { "id": "pX4a", "w": 1200, "h": 675 }, "like_count": 214, "comment_count": 18 } ], "newer_cursor": "eyJ2IjoyLCJsIjo0MDExNywiZCI6Im4iLCJ0cyI6MTc5MDU5Njc5MDA0MjExMywiaWQiOiI3MzE5MDIyMTQ0NzczOTgwMTYifQ.Hq2w", "older_cursor": "eyJ2IjoyLCJsIjo0MDExNywiZCI6Im8iLCJ0cyI6MTc5MDU5NjcxMTgwNDQ1MCwiaWQiOiI3MzE5MDE4ODI2MDM5ODMyMzIifQ.a8Lk", "has_newer": false, "has_older": true }
httpGET /v1/lists/40117/items?dir=older&limit=20&cursor=eyJ2IjoyLCJsIjo0MDExNywiZCI6Im8i...a8Lk HTTP/1.1 GET /v1/lists/40117/items?dir=newer&limit=20&cursor=eyJ2IjoyLCJsIjo0MDExNywiZCI6Im4i...Hq2w HTTP/1.1
Decoded, a v2 cursor names its list and its direction, so a cursor can't be used for another list or walked the wrong way:
json{ "v": 2, "l": 40117, "d": "o", "ts": 1790596711804450, "id": "731901882603983232" }
The server contract that makes this correct:
| Rule | Why |
|---|---|
Order is (sort_ts_us, item_id), a total order; sort keys never change after insert | Every item has exactly one place, and it doesn't move while someone scrolls. Edits bump version, not the sort key. |
dir=older returns the 20 items just below the cursor; dir=newer returns the 20 items just above it, nearest first | If newer returned the newest 20 instead, 300 new posts would leave a hole of 280 between the page and what the user already has. |
Every page carries both cursors, newer_cursor before its first item and older_cursor after its last | Any page can become an end of the phone's cached run, so the phone can trim pages from either end and still continue (step 2.2). |
Head and newer reads only return items older than the settle window (about 1.5 s in normal operation, below) | A post committed late with an earlier timestamp could otherwise land below a cursor the phone has already passed. A later refresh whose head page spans that post repairs it; the loss only sticks for posts that end up below the head page. |
limit up to 100 | Lets a fast fling ask for more in one request (step 2.5). |
| No total counts | Counting a topic's posts per request is expensive and stale at once. Placeholders use counts from the phone's own database. |
| Deleted posts leave the index | They simply stop appearing in pages; a cursor pointing at one still works, because it compares values. |
Why the settle window. created_at is stamped with the database clock when the row is inserted, but the row only becomes visible when its transaction commits, and our reads go to an Aurora reader a little behind the writer. So a post stamped at 12:00:00.100 could become visible after a post stamped 12:00:00.300 was already served as the head. A phone whose newest cursor is the 12:00:00.300 post asks for "newer than that" and doesn't see the 12:00:00.100 one. We bound both delays, per request, rather than trusting an alarm:
- Commit delay. A post is written by a single-statement
INSERT(autocommit), so the gap fromclock_timestamp()to commit is capped bystatement_timeout = 1 s. Multi-statement posting paths settransaction_timeout = 1s(PostgreSQL 17+). - Replica lag, measured. Each service task polls its reader's lag every second (
aurora_replica_status(), columnreplica_lag_in_msec) and uses for head andnewerreads. With normal lag well under 100 ms, that's about 1.5 s. - Lag unknown or above 5 s: head and
newerreads go to the writer, where lag is zero and the cutoff is now − 1.5 s. - The replica-lag alarm stays, but only to page a human; correctness never depends on someone answering it.
Within the cutoff, everything has committed and is visible on the reader that serves the request. An empty newer response therefore means "nothing settled yet", not "nothing ever": the next head check re-checks. The cost is that a new post appears in feeds about 1.5 seconds late.
2. A cheap head check. To show the "New posts" pill, the phone asks whether the head changed, with the head's ETag. Nearly all answers are 304 Not Modified with no body.
httpGET /v1/lists/40117/head HTTP/1.1 If-None-Match: "hv-7Qm2"
httpHTTP/1.1 200 OK ETag: "hv-8Bx9" Content-Type: application/json { "newest": { "item_id": "731903001234567168", "sort_ts_us": 1790596902113007 } }
The phone checks every 120 s, only while the list is on screen and the app is in the foreground. R2.6 sizes this rate; it's almost half as many requests as page loads.
3. Paging states, one per direction. The library keeps three independent load states: refresh, prepend and append. The UI shows a spinner or a retry row at the matching edge, and a refresh spinner at the top.
Synthesizing vector architecture diagram...
One of these runs for each direction. A refresh cancels the other two and blocks them until it finishes: the same priority rule Paging 3's RemoteMediator applies; its default LAUNCH_INITIAL_REFRESH also holds prepend and append until the first refresh succeeds.
4. Placeholders. A placeholder is an empty slot for an item we know exists but haven't loaded into memory. The list keeps its full length, so the scrollbar is honest and rows don't shift when a page loads or is dropped. The row draws a grey skeleton until the item arrives. We know the counts because they come from the phone's database, not the server.
The library's settings, next to Jetpack Paging 3's PagingConfig defaults:
| Setting | Our value | Paging 3 default | Why |
|---|---|---|---|
pageSize | 20 | (required) | Nearly three screens of 300 px rows (about 7 rows a screen) |
initialLoadSize | 40 | 3 × pageSize = 60 | One screen (about 7 rows) plus one page of prefetch is 27; 40 covers it with less data per open than 60 (R3.6 prices this default) |
prefetchDistance | 20; 60 on fast flings | pageSize | Velocity-aware (step 2.5). Paging 3's distance is fixed per Pager, so this is our addition. |
maxSize | 200 | unbounded | The memory window (step 2.2). Paging 3 requires maxSize ≥ pageSize + 2 × prefetchDistance: 20 + 2 × 60 = 140 ≤ 200. |
enablePlaceholders | on | on | Counts come from the local database |
jumpThreshold | off | off | Keyset can't jump to a position |
R2.4 Design Evolution: Database-Mediated Paging, a Bounded Window and Smooth Updates
Step 2.1: Offline, and Network Results Flicker In
The problem: the feed must open and scroll with no signal, from what we saved. Online, rows flicker as each network page replaces the list. And when the app is killed and reopened, we start from zero again. What would you do?
Deeper background on the local database (WAL mode, transactions, schema migrations): the offline-first news feed loop, steps 1.1 and 1.3 and Round 3.
Step 2.2: Memory Grows Without Limit
The problem: a user scrolls through 10,000 posts on a 2 GB Android phone. Memory climbs until the OS kills the app. Heap dumps show thousands of post objects and decoded images. What would you do?
Primitive: Distributed Cache Patterns & Eviction (the same "evict what's farthest from use" idea, applied to a window)
Step 2.3: The List Jumps When New Items Are Added at the Top
The problem: a user is reading the 30th post. They scroll up toward newer posts, or tap the "New posts" pill while still mid-list, and 20 newer posts are inserted above. The post they were reading jumps down by 20 rows, and they lose their place. What would you do?
Step 2.4: Pull-to-Refresh Races With Loading More
The problem: a user pulls to refresh while an append is still in flight. The refresh saves the newest posts; then the old append arrives and glues posts from before the refresh onto the list, or overwrites the edge cursor. Sometimes refresh also wipes the list, and if it then fails, the user is left with nothing. What would you do?
Step 2.5: A Fast Fling Fires 20 Requests
The problem: a user flings at 15,000 px/s. The network log shows a burst of requests: the same older_cursor requested several times, image downloads for dozens of rows nobody saw, and pages arriving out of order.
What would you do?
Primitive: API Protocols: HTTP/1.1 vs HTTP/2 vs HTTP/3 & gRPC (streams and per-stream cancellation)
Step 2.6: Rows Flicker, Animate Wrongly, or Crash
The problem: after a refresh, some rows flash and re-animate though nothing changed. A crash report from the Compose build says a key "was already used", and an iOS build throws an exception about non-unique item identifiers. What would you do?
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Offline; flicker | Database as the single source; remote mediator writes pages and edge cursors in one transaction; refresh keyed by the anchor's ID | A local database to size and trim |
| 2.2 | Memory grows | maxSize 200; pages dropped to placeholders and reloaded from SQLite; images capped by bytes; 2,000 items per list on disk | Reload work |
| 2.3 | The list jumps | Background diff; anchor kept still; "New posts" pill; prepend walks up from the reader | Diff work; anchor bookkeeping |
| 2.4 | Refresh races append | Per-direction states; a persisted generation checked inside every write; a merging refresh | State machine complexity |
| 2.5 | Fling stampede | Coalescing; velocity-aware prefetch in one larger request; cancel what was flown past | Tuning |
| 2.6 | Flicker and crashes | Server IDs; content versions; a primary key that makes repeats impossible | A server rule |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Read it right to left for data and left to right for requests. Nothing reaches the list UI except through the database, the window and the diff. The mediator is the only part that talks to the network, and it only writes to the database.
The pieces:
- The library on the phone: the remote mediator (states per direction, the generation fence, scroll-speed sensing, coalescing), SQLite as the source of truth, the memory window of 200 items with placeholders, and the background diff that feeds the list.
- Paging service on ECS (Fargate) behind an ALB: verifies cursors (signature, version, list, direction), runs keyset queries, applies the settle window, builds both cursors per page, answers head checks with ETags.
- Aurora PostgreSQL: one writer for new posts and edits, and three readers, one per AZ. Each service task reads from the reader in its own AZ through that instance's endpoint and falls back to the cluster reader endpoint if it's down. The cluster reader endpoint alone spreads connections across readers regardless of AZ, so about two thirds of result bytes would cross AZs.
- ElastiCache for Valkey: caches each topic's serialized head page (with its ETag) for 2 seconds; one primary and two replicas, one per AZ, and each task reads its own AZ's node.
- CloudFront, only for shared lists. A topic's head page is the same for everyone, so it could be cached at the edge. A personalized page (a home feed built for one user) can't be: every user's page differs, and caching it by user would add risk and no hits. R2.6 shows why we leave CloudFront off the default path and keep it for a public list that goes viral.
The server schema:
sqlCREATE TABLE post ( item_id BIGINT PRIMARY KEY, list_id BIGINT NOT NULL, -- the topic created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), -- database clock, microseconds version INTEGER NOT NULL DEFAULT 1, -- bumped by every edit author_id BIGINT NOT NULL, body TEXT NOT NULL, thumb_id VARCHAR(32), status VARCHAR(16) NOT NULL DEFAULT 'visible' -- visible, deleted, hidden ); CREATE INDEX post_list_order ON post (list_id, created_at DESC, item_id DESC) WHERE status = 'visible';
The three queries, all on post_list_order (the newer one scans it backward). statement_timestamp() is fixed for the statement, so the cutoff can be an index condition; clock_timestamp() changes row by row and can't:
sql-- head SELECT ... FROM post WHERE list_id = $1 AND status = 'visible' AND created_at < statement_timestamp() - $2::interval -- $2 = 1 s + observed lag + 0.5 s ORDER BY created_at DESC, item_id DESC LIMIT 21; -- dir=older SELECT ... FROM post WHERE list_id = $1 AND status = 'visible' AND (created_at, item_id) < ($2, $3) ORDER BY created_at DESC, item_id DESC LIMIT 21; -- dir=newer: the 21 nearest above the cursor, returned to the phone newest first SELECT ... FROM post WHERE list_id = $1 AND status = 'visible' AND (created_at, item_id) > ($2, $3) AND created_at < statement_timestamp() - $4::interval -- the same adaptive cutoff ORDER BY created_at ASC, item_id ASC LIMIT 21;
Go deeper: the head cache. Heads are read far more than any other page of a topic: every open, every refresh, and every head check whose ETag doesn't match. The service keeps a topic's serialized head page in Valkey for 2 seconds.
- A miss is filled once. Within a task, concurrent misses for the same topic wait on one in-flight query (single-flight), and the fill is written with
SET ... NX EX 2, so a slower filler can't overwrite a fresher one. If the cache restarts empty, the first wave of misses is at most one query per topic per task, not one per request, and the readers are sized to absorb every head uncached (R2.6). - Why not cache the head for longer, or forever? The head changes whenever someone posts; a head cached for an hour hides an hour of posts. A 2 s TTL bounds staleness to 2 s on top of the ~1.5 s settle window, and edits bump the ETag because it's computed from the IDs and
versions on the page. Deeper pages change too (edits, deletes), and their hit rate would be low anyway, since cursors differ between readers.
These are the two questions of the hot product page drill.
Trace 1: a fling toward older posts.
Synthesizing vector architecture diagram...
The duplicate trigger joins the running load, and one request of 60 replaces a chain of three.
Trace 2: new posts arrive while the user reads.
Synthesizing vector architecture diagram...
The overlap test compares sort keys, so it passes even if our old newest post was deleted.
Trace 3: scrolling offline.
Synthesizing vector architecture diagram...
Nothing in this trace touches the network except the one append at the end of the saved run, and its failure is a footer, not an error screen.
How a fling looks over time (a 15,000 px/s fling with 300 px rows is 50 rows a second; the mediator's planned fetch time is 1 s):
Synthesizing vector architecture diagram...
R2.6 Numbers and Cost
Traffic (the interviewer's figures; peak is 3× the average)
| Quantity | Math | Value |
|---|---|---|
| Page requests per day | 50M × 12 | 600M |
| Requests/s, average | 600,000,000 ÷ 86,400 | ≈ 6,944 |
| Requests/s, peak | 6,944 × 3 | ≈ 20,833, the "20K" target (we plan for 20.8K) |
| Head pages among them | 3 sessions a day × 1 head of 12 pages = 25% | ≈ 5.2K/s at peak |
| Keyset seeks among them | 75% | ≈ 15.6K/s at peak |
Head checks (assumption: the feed is on screen about 10 minutes a day, in the foreground; one check per 120 s while it is)
| Quantity | Math | Value |
|---|---|---|
| Checks per user per day | 600 s ÷ 120 s | 5 |
| Checks per day | 50M × 5 | 250M |
| Checks/s, average and peak | 250M ÷ 86,400; × 3 | ≈ 2,894; ≈ 8.7K |
A check every 30 s would be 1B a day and 34.7K/s at peak, more than all page requests together. The rate is a design decision we size, not a detail. (The ALB's default idle timeout is 60 s, so a check every 120 s usually opens a new connection; that's in the ALB line below as new connections, which stay well under the bytes dimension.)
Bytes on the wire (≤ 25 KB per 20-post page after gzip; we plan with 25 KB as the average, so this is an upper bound; requests are about 0.5 KB with HTTP/2 header compression; a head check answer is about 0.5 KB)
| Flow | Math | Value |
|---|---|---|
| Page egress at peak | 20,833 × 25 KB ≈ 521 MB/s | ≈ 4.2 Gbps |
| Page egress per month | 600M × 25 KB × 30.4 | ≈ 456 TB |
| Head-check egress per month | 250M × 0.5 KB × 30.4 | ≈ 3.8 TB |
| Request ingress per month | (600M + 250M) × 0.5 KB × 30.4 | ≈ 12.9 TB |
| Per user per day | 12 × 25 KB + 5 × 0.5 KB | ≈ 0.3 MB |
On the phone
| Item | Math | Value |
|---|---|---|
| Paging memory, low-end class | window + placeholders + views + 20 bitmaps (step 2.2) | ≈ 25.3 MB of a 30 MB budget |
| SQLite per list | 2,000 items × ~2 KB | ≈ 4 MB |
| SQLite, all lists | 5 lists × 4 MB, plus a WAL of up to ~4 MB between checkpoints | ≤ ~24 MB |
| A reload of a dropped page | one indexed query of 20 small rows | a few ms, off the main thread |
The fetch time we plan prefetch with (p95 on 4G, warm connection; each figure an assumption to check against field telemetry)
| Delay | Time |
|---|---|
| Trigger to request sent (up to one 120 Hz frame) | 8 ms |
| Radio wake-up, if the radio went idle | up to 200 ms |
| Network round trip | 150 ms |
| Server: ALB, service, keyset seek | 50 ms |
| Download 25 KB | 125 ms |
| Decode on a background thread | 15 ms |
| SQLite transaction | 15 ms |
| Invalidate and reload the window around the anchor | 5 ms |
| Background diff, | 3 ms |
| Apply on the next frame | 8 ms |
| Total | ≈ 579 ms; we plan with 1 s |
The main-thread budget. At 120 Hz the frame is 8.33 ms, and the system's rendering needs part of it; we give the app's main thread about half, ~4 ms, for binding, layout and applying diff results. At 15,000 px/s a 300 px row enters every ms, about every 2.4 frames, so one bind must fit comfortably in ~4 ms. That is why bind only sets fields and hands the image URL to the loader.
Database (assumptions, to confirm by load test: a keyset seek returning 21 rows costs about 0.4 ms of CPU when the index and hot rows are cached, so ~2,500 seeks a second per vCPU; the three-year catalog is 500M posts)
| Quantity | Math | Value |
|---|---|---|
| Seeks at peak, cache working | 15.6K keyset + head misses | ≈ 16–18K/s |
| Seeks at peak, head cache empty | 15.6K + 5.2K head pages + 8.7K head checks | ≈ 29.5K/s |
Capacity per db.r7g.2xlarge reader | 8 vCPUs × 2,500 | ≈ 20K/s |
| Worst case per reader, one AZ lost | 29.5K ÷ 2 | ≈ 14.75K/s (74%) |
| Table and index | 500M × 2 KB; 500M × ~64 B | ≈ 1 TB; ≈ 32 GB (the index fits in a reader's 64 GiB with room for recent rows) |
| New posts | 50M × 0.2 a day | 10M a day, ≈ 116/s average: easy for one writer |
Fleet (assumptions: a 2 vCPU / 4 GB task serves ~700 page requests a second; a head check costs a quarter of a page; every fleet must carry the peak with one AZ lost, and we round per AZ)
| Fleet | Peak need | With one AZ lost | Count |
|---|---|---|---|
| Paging service | (20,833 + 8,700 × 0.25) ÷ 700 ≈ 23K ÷ 700 ≈ 33 | 2 AZs carry 33 → 17 per AZ | 51 tasks |
| Aurora readers | ≈ 29.5K/s worst case ÷ 20K each | 1 per AZ; 2 left carry 74% each | 3 + 1 writer |
| Valkey head cache | ~10K head pages alive for 2 s × 25 KB ≈ 0.3 GB | 1 node per AZ | 3 × cache.r7g.large |
Monthly cost (us-east-1 on-demand list prices, rounded; tiers are list prices, and other regions list higher)
| Item | Math | Monthly |
|---|---|---|
| Data transfer out | ≈ 460 TB: 10 TB × $90 + 40 TB × $85 + 100 TB × $70 + 310 TB × $50 per TB | ≈ $26.8K |
| ALB | processed bytes dominate: ≈ 473 TB a month ≈ 473,000 LCU-hours × $0.008, + $0.0225 × 730 | ≈ $3.8K |
| ECS on Fargate (Graviton), 51 × (2 vCPU, 4 GB) | 51 × (2 × $0.03238 + 4 × $0.00356)/h × 730 h | ≈ $2.9K |
Aurora PostgreSQL, 4 × db.r7g.2xlarge, Aurora Standard | 4 × $1.106/h × 730 h | ≈ $3.2K |
| Aurora storage and I/O | 1 TB × $0.10/GB-month + I/O for cold, deep pages (budget) | ≈ $2.1K |
ElastiCache for Valkey, 3 × cache.r7g.large | 3 × $0.175/h × 730 h | ≈ $0.4K |
| Cross-AZ | same-AZ readers and cache nodes; residual failover and fill traffic | ≈ $0.1K |
| CloudWatch, logs, WAF, misc. | ≈ $3.0K | |
| Total | ≈ $42.3K/month |
Four notes on that table:
- Bytes are the bill. Data transfer out is 63% of it. The cheapest page is one not fetched:
initialLoadSize40 instead of 60, and one page of prefetch instead of three when reading. - Why readers in the tasks' own AZ. A seek returns 21 rows of about 2 KB each, about 42 KB: 450M seeks a day × 42 KB × 30.4 ≈ 575 TB a month. Through the cluster reader endpoint, about two thirds would cross AZs at $0.02/GB: ≈ $7.7K a month for nothing.
- Why not API Gateway or CloudFront in front of the API. At 18.24B page requests a month, API Gateway's HTTP API list price ($1.00 per million for the first 300M, $0.90 after) is about $16.4K, four times the ALB's $3.8K. CloudFront's request fee alone ($0.01 per 10,000 HTTPS requests in the US) would be about $18.2K, more than the edge saves on bytes. CloudFront earns its place only for a shared list so hot that edge hits save the origin real work.
- Aurora I/O. AWS suggests Aurora I/O-Optimized when I/O is more than about a quarter of the Aurora bill. Our I/O budget is about $2K of $5.3K (38%), over AWS's 25% guideline. At these numbers I/O-Optimized would cost about 4 × $1.44/h × 730 + 1 TB × $0.225 ≈ $4.4K, saving about $0.9K a month (assumptions: I/O-Optimized instances about 30% above Standard, storage $0.225/GB-month). We start on Standard only because $2K is a budget, not a measurement, and switch once a month of real I/O confirms it.
That's about $0.00085 per daily user per month, and delivery is most of it.
R2.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Database-mediated vs network-only paging | The database is the only source for the list | A local schema to size, trim and migrate, and a write before every display. Network-only is simpler for lists that are never read offline and change every time (search results; Round 3). |
| Window size | 200 items | Bigger: more memory, fewer reloads. Smaller: reloads from SQLite while scrolling back, and maxSize must stay ≥ pageSize + 2 × prefetchDistance (140 here). |
| Prefetch distance | 20 reading, 60 flinging | Further: fewer loading rows, more wasted data when people stop. Closer: less waste, more visible spinners. |
| Auto-insert vs "New posts" pill | The pill, except at the very top | Some users never tap it and see new posts later. Auto-insert moves content under the reader's thumb. |
| Placeholders | On, counted locally | The UI must draw skeleton rows and estimate heights; in return, nothing shifts when pages load or drop. |
| Settle window | 1 s + observed lag + 0.5 s (≈ 1.5 s) | New posts appear about 1.5 s later. Without it, a late commit below the head page is missed by every phone above it. |
| Head-check interval | 120 s while visible | Faster checks feel livelier and cost almost linearly more requests; pushes over a realtime channel (notification loop) scale better once an app already keeps one open. |
R2.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| Scroll jitter on prepend | The post being read jumps | Anchor kept at its pixel offset in the same frame; alarm on the jump rate from telemetry (R3.6) |
| Refresh/append race | Old posts glued onto a refreshed list | Refresh bumps the persisted generation and cancels loads; every write re-checks generation, account and edge cursor inside its transaction |
CursorWindow limits (Android) | SQLiteBlobTooBigException: Row too big to fit into CursorWindow, or CursorWindowAllocationException | Query results cross into the app through a CursorWindow, 2 MB by default. list_item holds only small columns (no bodies, no images), queries name their columns (no SELECT *), and every cursor is closed promptly (Room does this; hand-written queries close each cursor in a finally step) so leaked windows don't exhaust memory. |
| Fling stampede | Burst of duplicate requests | Coalescing by (list, direction, cursor); one larger request; recycled rows cancel their image loads |
| Out of memory on low-RAM phones | App killed after a long session | maxSize 200; image cache capped in bytes; on onTrimMemory / memory warnings, shrink the image cache first |
| Timestamp collisions | Two posts in the same microsecond | (created_at, item_id) is a total order; the cursor holds both |
| A request outlives its retry | The server works on a request the phone gave up on | Client attempt timeout 10 s; service deadline 3 s; statement_timeout 1 s. The original is gone before the retry is sent, and a GET is harmless to repeat anyway. |
| A retry storm after an outage | Millions of phones retry together | Full-jitter backoff: wait a random time between 0 and min(30 s, 1 s × 2^attempt); honour Retry-After on 429/503; after 3 automatic tries, show "Tap to retry" |
| Token refresh during a scroll | Several calls get 401 at once | One refresh at a time (single-flight). The auth server accepts the previous refresh token for a short grace window (say 30 s) after rotating it, so a refresh whose response was lost can be repeated without logging the user out. |
| Replica lag grows | Late posts could be missed by prepends | The cutoff widens by itself: each task uses now − (1 s + observed lag + 0.5 s); if lag is unknown or above 5 s, head and newer reads go to the writer. The > 1 s alarm only pages a human. |
R2.9 Production Gotchas
| Gotcha | Why it's wrong |
|---|---|
OFFSET for the feed | Repeats and gaps as posts arrive; deep pages cost . (An offset over the phone's own small table is fine: Room's generated PagingSource does exactly that.) |
| Diffing on the main thread | A 1,000-item diff with 200 changes measured 13.5 ms on an older phone: more than a 120 Hz frame. |
| Wiping the list on refresh | A blank screen during the fetch, and nothing left if it fails. Merge instead. |
| Unstable IDs (index or random UUID) | Every row looks changed; animations and scroll position break. Duplicate keys crash Compose and diffable data sources. |
dir=newer returns the newest page | Leaves a hole between it and what's saved. Walk up from the cursor, nearest first. |
| Overlap checked by "is our newest post in the new page?" | Fails when that post was deleted. Compare sort keys. |
| The generation kept in memory | A restarted process starts at 1 and accepts stale writes. Keep it in the database. |
R2.10 Pillar Check
| Pillar | What Round 2 covers |
|---|---|
| Reliability | Offline reading from the local database; refresh that merges and can fail safely; generation-fenced writes; full-jitter retries with server deadlines shorter than client timeouts; readers in every AZ REL 11 · REL 5 · REL 10 |
| Performance Efficiency | 8.33 ms frames with a ~4 ms main-thread share; background diffs; a bounded window; keyset seeks sized for the peak with an AZ lost; a head cache PERF 3 · PERF 2 |
| Security | Cursors signed and bound to a list and direction; every local write checks the signed-in account; the paging database lives in the app's private storage SEC 3 · SEC 8 |
| Cost Optimization | ≈ $42.3K a month, 63% data transfer; readers in the caller's AZ save ≈ $7.7K; ALB instead of API Gateway or CloudFront for the API COST 8 · COST 5 |
| Operational Excellence | A settle cutoff that adapts to measured replica lag, with an alarm that pages a human; reader CPU against the AZ-loss plan, and cache hit rate OPS 8 |
| Sustainability | No prefetch while stopped; image loads cancelled for rows flown past; head checks only while visible; 40 initial items instead of 60 SUS 3 · SUS 2 |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Makes the local database the single source of truth and explains the remote mediator's job, including invalidation and how the scroll position survives it.
- Bounds memory with a window, placeholders and reloads, and does the memory arithmetic, with images as the real budget.
- Designs prepend on the server as "nearest first" and handles late commits with a settle window.
- Keeps the anchor still on inserts and diffs off the main thread, citing a real cost.
- Fences refresh races with a persisted generation checked inside each write, and merges on refresh by comparing sort keys.
- Coalesces loads, adapts prefetch to velocity, and cancels flown-past work.
- Derives traffic, head-check rates, bytes, fleets with an AZ lost, and a cost dominated by bytes.
Follow-up questions
-
"Why not just use Paging 3's defaults?" Answer: they're a good start, but two matter at scale:
maxSizedefaults to unbounded, so without setting it memory grows with the session; andinitialLoadSizedefaults to three pages, which at our traffic is data many users never see. Prefetch that changes with velocity isn't built in, so we add it around thePager. -
"The user rotates the phone, or the OS kills the app in the background. What survives?" Answer: the database holds the items, cursors, generation and the anchor item's ID. On recreation, the list loads the window around that anchor from SQLite and shows it with no network call.
SKIP_INITIAL_REFRESHapplies if the saved data is fresh, so we don't refresh just because the process restarted. -
"A post is edited while it's on screen. What happens?" Answer: this round, the edit shows up when a page containing it is reloaded from the network (a refresh overlapping it, or a head load): its higher
versionupdates the row in place, and the diff reports a change, not a move. Edits far down a long list can be stale for a while. Round 3 adds an update channel.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Show network results and also save them" | Two sources disagree; flicker. The screen reads only the database. |
| "Keep all posts; text is small" | 20 MB for 10,000 posts before images; bound the window and cap images by bytes. |
| "Cache every page at the CDN" | Personalized pages can't be shared; even shared lists pay a request fee per request. |
| "Fetch pages 2, 3 and 4 in parallel on a fling" | Keyset pages form a chain; ask for one larger page. |
| "Poll the head every few seconds" | 50M users make that the biggest request stream in the system. Size it. |
Round 3 · Architect · "One Library for Every Team, Platform and Feed Type"
~45 min · Principal (L7) · 1 region, 3 AZs for our backends · 80M DAU · 2.4B list-page loads/day across 20 teams (≈ 83K/s at peak), 960M of them on our feed backend · iOS, Android and web · per-device-class budgets
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Rounds 1 and 2 in 60 seconds. "We page by keyset cursor, the last item's
(created_at, item_id), signed and opaque, so inserts and deletes never repeat or skip items and a deep page costs one index seek. In Round 2 the feed became 50M daily users, 600M pages a day, about 20.8K requests a second at peak. The phone's SQLite database is the single source of truth: a remote mediator writes pages and their edge cursors in one transaction, keeping the saved items one contiguous run, and the list reads a window of at most 200 items around the user, with placeholders for the rest and reloads from SQLite. Diffs run off the main thread and the anchor item stays still on inserts; new posts wait behind a 'New posts' pill found by a head check every 120 s. Refresh bumps a persisted generation that every write re-checks, and merges by comparing sort keys. The server walksnewerpages nearest first and serves only posts older than a settle cutoff of 1 s plus measured replica lag plus 0.5 s, about 1.5 s, reading from the writer if lag is unknown or over 5 s. Low-end phones stay at about 25 MB of paging memory; frames are 8.33 ms. About $42.3K a month, 63% of it data transfer. Open costs: it's built for one app and one backend, edits far from the top go stale, and we can't see how it behaves in the field."
Architecture v2, compact
Synthesizing vector architecture diagram...
Round 2 in one picture: the network writes the database, and the list only ever reads it.
Round 2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Offline; flicker | Database as the single source; remote mediator | A local database |
| 2.2 | Memory grows | maxSize 200; placeholders; byte-capped images | Reload work |
| 2.3 | The list jumps | Background diff; anchor kept still; "New posts" pill | Diff work |
| 2.4 | Refresh races append | Persisted generation checked in each write; merging refresh | A state machine |
| 2.5 | Fling stampede | Coalescing; velocity-aware prefetch; cancellation | Tuning |
| 2.6 | Flicker and crashes | Server IDs and versions; primary key blocks repeats | A server rule |
Open costs: tied to one API and one schema; no in-place updates for items off the top; no accessibility design; no versioning or telemetry.
R3.1 The Scope Raise
Interviewer: "Twenty teams want your library: chat history, search results, the ranked home feed, order history, settings lists. We ship on iOS, Android and the web. The ranked feed reorders between requests. Items get edited and deleted while people look at them. Screen-reader users must be able to use every list. And whatever you ship, you can't break twenty teams' apps."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Do all these lists have a keyset API like ours? | No. Search has a ranked, token-based API; chat has message sequence numbers; order history uses offsets today; settings is a local array. | The library can't know any API. Teams plug in a data source that follows a contract (step 3.1). |
| Must every list work offline? | Chat and feeds yes; search and settings no. | The database-mediated mode is one option, not the only one. |
| How does the ranked feed page today? | The server ranks candidates on the first request; scores change every minute. | A ranking session that pins the order (step 3.2). |
| How do edits and deletes reach phones? | The app already has a sync channel with a per-user change log. | The library takes item updates from it, through the database (step 3.3). |
| What does accessibility require? | Every list usable with TalkBack, VoiceOver and a keyboard on web; nothing announced as blank. | Announcements, stable focus, and ways to jump and to reach the end (step 3.4). |
| How is the library shipped? | Inside each team's app, on their release schedule. Some apps ship every week, some every month. | We can't recall a bad version: we need semantic versioning, a compatibility suite, and remote switches (step 3.5). |
| How much traffic? | 80M DAU; about 30 list-page loads each a day across all list types, 12 of them on the feed. | 2.4B loads a day; 960M on our feed backend (R3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Users of the library | One team, one feed | 20 teams: feeds, chat, search, orders, settings |
| Platforms | iOS and Android | iOS, Android and web |
| Backends | Our keyset API | Any backend, through a data-source contract |
| Ordering | Chronological | + ranked feeds that reorder between requests |
| Updates | Seen when a page reloads | Edits and deletes applied in place while visible |
| Traffic | 600M pages/day | 2.4B list-page loads/day (≈ 83K/s at peak); 960M/day on our feed backend |
| Devices | One budget for low-end phones | Budgets per device class |
| Operations | None | Telemetry, accessibility, a stable API, safe releases |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
The library calls /v1/lists/{id}/items | Chat, search and orders have different APIs; every team would fork the library. |
Keyset on (created_at, item_id) | Ranked order isn't a stable column: scores change between requests, and aren't unique. |
| Edits seen only when a page reloads | An edited price, or a deleted message, stays wrong on screen until the user refreshes. |
| No accessibility design | Screen readers announce placeholders as blank, lose focus when items are inserted, and keyboard users can never reach the footer of an endless list. |
| Library changes shipped freely | One changed default or renamed method breaks twenty apps, on release schedules we don't control. |
| No telemetry | We don't know jank rates, time to first page, or whether duplicates and gaps happen in the field. |
R3.3 New Requirements and API Additions
1. The data-source contract. Teams implement one operation; the library owns everything else (the database cache, the window, diffing, states, prefetch, retries). It's a contract, not code: inputs, outputs and rules.
| Part | Contract |
|---|---|
| Input | load(key, direction, size): key is opaque and absent for a refresh; direction is refresh, older or newer; size is how many items the library would like |
| Output: page | items; older_key and newer_key (each absent at that end); optional items_before / items_after counts, which enable placeholders |
| Output: error | retryable (true for timeouts and 5xx, false for 4xx) and an optional retry_after |
| Output: invalid | "This key no longer works" (for example, an expired ranking session). The library refreshes around the anchor item. |
| Item | A unique, stable id; a version or a content-equality rule for diffing; optionally a sort key for the database mode |
| Rules | Keys are opaque to the library. newer returns the items nearest the key first. A page must make progress: returning the key it was given, or the same key twice in a row, is an error. IDs never repeat across pages of one session. |
| Mode | network (in-memory window only: search), database (the Round 2 design: feeds, chat), or static (a local array: settings) |
This deliberately mirrors Paging 3's PagingSource (LoadParams.Refresh, Append, Prepend; LoadResult.Page, Error, Invalid), so Android teams recognize it, while the spec stays platform-neutral for iOS and web.
2. Ranked-feed cursors. For ranked lists, the server stores the ranked order for a session, and the cursor points into it:
json{ "v": 3, "m": "ranked", "sid": "fs_8k2Q", "o": 40 }
A cursor for an expired session returns:
httpHTTP/1.1 410 Gone Content-Type: application/json { "error": "SESSION_EXPIRED", "action": "refresh" }
3. The item-update channel.
| Message | Meaning | Library action |
|---|---|---|
upsert(list_id, item, version) | An item changed or appeared | Write it if version is newer than the stored one; the list diffs it in place |
remove(list_id, item_id, version) | An item was deleted or hidden | Delete the row, keep a small tombstone; the list closes the space |
invalidate(list_id) | Too much changed to patch (for example, the user's filters changed) | Refresh around the anchor |
4. Accessibility behaviours (step 3.4): announce loading, errors and "N new items"; never move focus on inserts; expose position and set size; offer "load more" and jump controls.
5. Telemetry hooks (step 3.6): the library emits named events (first page shown, frame hitch, load error, duplicate dropped, overlap failed, memory high-water); the app forwards them to its telemetry pipeline, aggregated per install per day.
R3.4 Design Evolution: A Contract, Sessions, Updates, Access, Stability, Telemetry
Step 3.1: Every Team Has a Different Backend
The problem: chat pages by message sequence number, search by a ranking token, orders by offset, and settings is a local array. Each team is about to copy our paging code and change it. What would you do?
Step 3.2: Ranked Feeds Reorder Between Pages
The problem: the home feed is ranked by a model. Between page 1 and page 2, scores change: a post that was 25th is now 12th. Users see the same post twice, and some posts never at all. What would you do?
Step 3.3: An Item Was Edited or Deleted While on Screen
The problem: a seller changes a price, a chat message is deleted, a post is edited, all while visible. The screen shows the old version until the user refreshes. What would you do?
Step 3.4: Screen Readers Get Lost in Endless Lists
The problem: with TalkBack or VoiceOver, loading placeholders are read as blank rows; when new items arrive above, focus jumps; on the web, keyboard users can never reach the page footer because more items keep loading. What would you do?
Step 3.5: Library Updates Break Teams
The problem: we renamed a method and changed the default initialLoadSize. Three teams' builds broke; a fourth team's app now loads three times as much data on open, and they didn't notice for a month.
What would you do?
Go deeper: testing a paging library.
- Unit tests with a scripted fake data source: it serves a list that changes between calls, and the test asserts the correctness rule from Round 1: every item that existed for the whole scroll appears exactly once, for random sequences of loads, inserts, deletes, refreshes and drops. Android's
paging-testingartifact (TestPager,asSnapshot) runs a realPageragainst such a fake. - Race tests: pause a response, start a refresh, release the response, and assert the generation fence rejects it. Kill the process mid-transaction and assert the run is still contiguous on restart.
- Frame tests on real low-end devices: Macrobenchmark's
FrameTimingMetricon Android, XCTest's scroll performance metrics on iOS, with a fling script, tracked per release. - Network conditioning: slow 3G, packet loss and airplane mode during a load.
- Accessibility passes with TalkBack, VoiceOver and a keyboard-only web run.
Step 3.6: Is Paging Healthy Across Apps?
The problem: a team says "the feed feels janky since last week." Another suspects duplicates on older Android versions. We have no data. What would you do?
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | Every team has a different backend | Data-source contract; modes (network, database, static); results validated | A public contract to keep |
| 3.2 | Ranked feeds reorder | Ranking session per reader; offset into a frozen list; 410 → new session appended below | Server-side session state |
| 3.3 | Edits and deletes while visible | Updates through the database, version-guarded, with tombstones; diffs in place | An update channel |
| 3.4 | Screen readers get lost | Announcements, stable focus, set size, load-more mode, jumps | Design and testing effort |
| 3.5 | Updates break teams | Semver, API dumps in CI, conformance kit, real-app suite, remote switches | Slower evolution |
| 3.6 | No visibility | Per-install daily summaries, sampled traces, distinct-install thresholds | Data volume and privacy review |
R3.5 Global Architecture
The library, per platform. One written spec and a shared set of test vectors; three native implementations with the same layers.
Synthesizing vector architecture diagram...
Teams write only the top box of the middle panel. The same layers run on each platform; what differs is the UI adapter at the bottom and the storage underneath the cache.
Platform notes:
- Android: built on Jetpack Paging 3 (
Pager, RoomPagingSource,RemoteMediator,PagingDataAdapterorLazyPagingItems), wrapped with our contract, velocity-aware prefetch and validation. - iOS: our own implementation: SQLite through GRDB or Core Data, an observation feeding a diffable data source,
UICollectionViewDataSourcePrefetchingfor early image loads, and offset compensation on inserts above the viewport. - Web: the same contract over IndexedDB (database mode) or memory; a virtualized list that renders only the visible rows; an
IntersectionObserveron a sentinel row as the prefetch trigger; a page limit in the query cache (for example TanStack Query'smaxPagesfor infinite queries) as the window; CSS scroll anchoring (overflow-anchor, which MDN marks as Baseline 2026, so older browsers lack it), and the same offset compensation as iOS where it's missing.
The backends and pipelines behind it.
Synthesizing vector architecture diagram...
The library talks to many backends, but only through the data sources teams wrote. Our own backends are the feed paging service, the ranking-session store the feed service uses, and the telemetry pipeline.
Trace 1: a ranked feed across three pages, with an expiry.
Synthesizing vector architecture diagram...
Pages 2 and 3 read a frozen list, so they can't repeat or skip; after expiry the library appends a new session below the reader instead of jumping.
Trace 2: an edit while the item is visible.
Synthesizing vector architecture diagram...
A late copy of version 3 arriving afterwards changes nothing, because the stored version is already 4.
R3.6 Numbers and Cost
Memory budgets per device class (assumptions to validate against the memory high-water and OOM telemetry; detected at start-up from total RAM, Android's isLowRamDevice() and memory class, and on iOS from physical memory plus os_proc_available_memory() for live headroom)
| Class | Paging budget | maxSize | Full-width 16:9 thumbnail | Image cache (20 bitmaps) | Total |
|---|---|---|---|---|---|
| Low (≤ 3 GB RAM, 720 px wide) | 30 MB | 200 | ≈ 1.17 MB | ≈ 23.3 MB | 0.4 + 0.08 + 1.5 + 23.3 ≈ 25.3 MB |
| Mid (4–6 GB, 1080 px) | 60 MB | 300 | ≈ 2.63 MB | ≈ 52.5 MB | 0.6 + 0.08 + 2 + 52.5 ≈ 55.2 MB |
| High (≥ 8 GB, 1440 px) | 120 MB | 400 | ≈ 4.67 MB | ≈ 93.3 MB | 0.8 + 0.08 + 2.5 + 93.3 ≈ 96.7 MB |
| Web | DOM rows, not MB | 10 pages (200 items) | browser-managed | browser-managed | ~30 rendered rows |
Every maxSize stays at or above pageSize + 2 × prefetchDistance = 140.
Load across all teams (the interviewer's figures; peak 3× average)
| List type | Loads per user per day | Loads per day | Peak/s |
|---|---|---|---|
| Feeds (ours: chronological and ranked) | 12 | 960M | ≈ 33.3K |
| Chat history | 8 | 640M | ≈ 22.2K |
| Search results | 6 | 480M | ≈ 16.7K |
| Orders and others | 4 | 320M | ≈ 11.1K |
| Total | 30 | 2.4B | ≈ 83.3K |
Defaults are capacity. About 800M list opens a day (10 per user). For lists whose data source fetches initialLoadSize from the network (network mode, and refreshes that use it), if a library release moved initialLoadSize from our 40 to Paging 3's default of 60, each open would load 20 more items, about 25 KB: TB a day, about 608 TB a month more egress across the teams' backends, roughly $30K a month at the $0.05/GB tier, plus their database reads. A default is a capacity decision for 20 backends at once.
Feed backend (Round 2's per-request assumptions; head checks now 80M × 5 = 400M a day, ≈ 13.9K/s at peak)
| Fleet | Peak need | With one AZ lost | Count |
|---|---|---|---|
| Paging service (2 vCPU, 4 GB) | (33.3K + 13.9K × 0.25) ÷ 700 ≈ 36.8K ÷ 700 ≈ 53 | 27 per AZ | 81 tasks |
Aurora readers (db.r7g.2xlarge) | worst case, head cache cold: 33.3K pages + 13.9K head checks ≈ 47.2K/s | 2 per AZ; 4 left carry ≈ 11.8K/s each, 59% of 20K | 6 + 1 writer |
| Valkey head cache | as Round 2 | 1 per AZ | 3 × cache.r7g.large |
Ranking sessions (assumptions: 3 ranked sessions per user per day; 500 IDs of 8 bytes each, stored as one packed string per session; 30-minute expiry after the last read; peak 3× average)
| Quantity | Math | Value |
|---|---|---|
| Sessions per day | 80M × 3 | 240M |
| New sessions/s, average | 240M ÷ 86,400 | ≈ 2,778 |
| Live sessions, average | 2,778/s × 1,800 s | ≈ 5.0M |
| Live sessions, peak | × 3 | ≈ 15M |
| Memory at peak | 15M × ~4.1 KB (4,000 B of IDs + key and overhead) | ≈ 61.5 GB |
| Nodes | cache.r7g.xlarge (26.32 GiB) filled to ~75%: ≈ 21 GB each → 3 shards needed; we run 4 for headroom, each with a replica in two other AZs | 12 nodes |
| Session reads and expiry refreshes | half of feed pages are ranked: 480M a day, ≈ 16.7K/s at peak; each is a range read plus an expiry refresh on the primary | ≈ 33K commands/s over 4 primaries |
Telemetry (one ~2 KB daily summary per active install; 1% of installs add ~50 KB of traces)
| Quantity | Math | Value |
|---|---|---|
| Summaries per day | 80M × 1 | 80M |
| Peak records/s | 80M ÷ 86,400 × 3 | ≈ 2,778 |
| Peak bytes/s, summaries | 2,778 × 2 KB | ≈ 5.56 MB/s |
| Peak bytes/s, traces | 40 GB ÷ 86,400 × 3 | ≈ 1.39 MB/s |
| Peak bytes/s, total | 5.56 + 1.39 | ≈ 6.95 MB/s ≈ 6.6 MiB/s |
| Firehose billed per day | 80M × 5 KB (each record rounds up to 5 KB) | 400 GB |
| Traces per day | 800K × 50 KB | 40 GB |
Quota check. A Firehose stream with Direct PUT in us-east-1 defaults to 5 MiB/s (with 2,000 requests/s and 500,000 records/s). Our peak of about 6.6 MiB/s is over it. We split the stream by platform: three streams at about 2.2 MiB/s each, well under the default, and we confirm the quotas before launch. The collector batches records with PutRecordBatch, which keeps us far under the request limit but doesn't change bytes per second.
Monthly cost (us-east-1 on-demand list prices, rounded; other regions list higher)
| Item | Math | Monthly |
|---|---|---|
| Feed data transfer out | ≈ 736 TB (960M pages × 25 KB × 30.4 + head checks): 10 TB × $90 + 40 × $85 + 100 × $70 + 586 × $50 per TB | ≈ $40.6K |
| ALB | ≈ 756 TB processed (responses + ~0.5 KB requests) ≈ 756,000 LCU-hours × $0.008 | ≈ $6.1K |
| ECS on Fargate, 81 paging tasks | 81 × $0.079/h × 730 h | ≈ $4.7K |
Aurora PostgreSQL, 7 × db.r7g.2xlarge | 7 × $1.106/h × 730 h | ≈ $5.7K |
| Aurora storage and I/O | 1 TB × $0.10/GB-month + ≈ $3.0K I/O budget | ≈ $3.1K |
Valkey head cache, 3 × cache.r7g.large | 3 × $0.175/h × 730 h | ≈ $0.4K |
Valkey ranking sessions, 12 × cache.r7g.xlarge | 12 × $0.3496/h × 730 h | ≈ $3.1K |
| Telemetry | Firehose ≈ 13.4 TB billed × $0.029/GB ≈ $0.39K; Parquet conversion ≈ $0.24K; collector 3 tasks ≈ $0.09K; S3 and Athena ≈ $0.5K | ≈ $1.2K |
| CloudWatch, logs, WAF, misc. | ≈ $6.0K | |
| Total | ≈ $71K/month |
Aurora I/O, again. The I/O budget is about $3.0K of $8.8K of Aurora spend (34%), over AWS's 25% guideline. I/O-Optimized would cost about 7 × $1.44/h × 730 + 1 TB × $0.225 ≈ $7.6K, saving about $1.2K a month (assumptions: instances about 30% above Standard, storage $0.225/GB-month). As in Round 2, we start on Standard because $3.0K is a budget, not a measurement, and switch once a month of real I/O confirms it.
Delivery is still more than half the bill (57%). The line the library team controls most is the one not in the table: the defaults that set how much 20 teams' backends send (above).
R3.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| A shared library vs per-team code | One library, one contract | A platform team to staff and a public API to support. Per-team code is faster to start, but 20 copies of the same race conditions and accessibility bugs cost more in effort over time COST 11. |
| Ranking sessions vs keyset | Sessions for ranked lists, keyset for chronological | Session state and an expiry path; keyset needs neither but can't page a moving order. |
| Flexibility vs API stability | A small contract (one load operation, three modes) | Some teams want hooks we don't offer; every hook we add is a promise for years. |
| One shared codebase vs native per platform | One spec and test vectors, three native implementations | Three implementations to keep in step. A shared cross-platform core would remove that, but ties every app's UI thread and threading model to one toolchain, and the hardest parts (diff application, anchors, accessibility) are platform-specific anyway. |
| Database mode for every list | Only where offline or long sessions need it | Search results in network mode vanish when the app restarts; they'd be stale by then anyway. |
| Telemetry detail vs privacy | Daily per-install summaries, 1% traces, no content | Some bugs need more detail than a summary; we raise sampling for one app version when needed. |
Closing the loop. The question was how to make an endless list feel instant and stable while holding only a little of it. Round 1 answered "page by position in a total order, and build views only for what's visible". Round 2 answered "make the phone's database the single source, keep a bounded window, and apply every change as a diff that keeps the reader's place". Round 3's answer is that the list is only as correct as the contract under it: stable identities, keys that make progress, orders that don't move during a session, and a library that checks all three and tells us when they break.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A bad library release | Jank or crashes rising in apps that adopted it | It reaches users only as apps ship it, so we catch it in the compatibility suite and the first app's staged rollout (Play staged rollout, App Store phased release over 7 days). Once shipped: turn the behaviour off with its remote switch; teams pause their rollouts; a patch release follows. |
| Ranking session expired mid-scroll | Nothing visible | 410 → new session, appended below, duplicates dropped. |
| One team's data source is buggy | That list shows an error or stops loading | Validation drops duplicates and stops non-advancing keys; the damage stays in one list and is reported with the team's list type, not a crash of the app. |
| Library database migration fails | Slower first open | The tables are a cache: drop and rebuild, then refresh. |
| Update channel lags or reorders | An edit shows a few seconds late | Version guard and tombstones make order irrelevant; the next page load also carries the latest version. |
| Telemetry flood from one app version | Firehose throttling, dashboard spikes | Distinct-install thresholds; the collector samples down a noisy version; the switch for that app's detailed traces goes off. |
| Session store loses a shard | Some ranked cursors get 410 | The same expiry path: new session, appended below. The replica in another AZ takes over. |
R3.9 Runbook and Incident Response
Golden signals, per library version, app, platform and device class OPS 8 · OPS 4
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Jank rate (hitch time or slow frames while scrolling) | 1.5× the previous library version on the same app, with ≥ 10,000 installs | P2 | Compare by device class; check diff sizes and bind time in traces |
| Time to first page (p95) | +30% vs the previous version | P2 | Database vs network split; a slow migration or a larger initialLoadSize |
| Duplicate or gap detections | > 0.1% of distinct installs in a day | P1 | Which list type and data source; server sort-key changes; a merge bug |
| OOM rate / crash-free users | Crash-free below 99.5% on a version, or OOM rate 2× | P1 | Memory high-water by device class; image cache bytes |
| Backend P99 (feed) | > 150 ms for 10 min at the ALB | P2 | Reader CPU and replica lag; head cache hit rate |
| Replica lag | > 1 s | P2 | The service already widens its cutoff and moves to the writer above 5 s; find why the reader lags (load, long queries) |
| Firehose throttled records | Any sustained | P3 | Split the stream or raise the quota |
Library rollback procedure OPS 6 · REL 8
- Confirm it's the library. Compare the signal between apps on the new version and apps still on the old one, same device class.
- Turn the behaviour off. Flip the remote switch for the feature the release changed (CLI 4); signals should recover as apps pick up the new configuration.
- Stop the spread. Ask the affected teams to pause their staged rollouts; mark the version as "do not adopt" in the library registry.
- Patch forward. Ship a patch release with the old behaviour as the default; teams take it in their next release. There's no "rollback" of code already on phones.
- Record it. Add the missing scenario to the conformance kit or the compatibility suite.
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace the IDs with real ones.
text# 1. Feed API P99 at the load balancer, last hour aws cloudwatch get-metric-statistics --namespace AWS/ApplicationELB --metric-name TargetResponseTime --dimensions Name=LoadBalancer,Value=app/paging-alb/50dc6c495c0c9188 --extended-statistics p99 --period 60 --start-time 2026-09-28T09:00:00Z --end-time 2026-09-28T10:00:00Z # 2. Aurora replica lag on one reader (the service adds it to its settle cutoff) aws cloudwatch get-metric-statistics --namespace AWS/RDS --metric-name AuroraReplicaLag --dimensions Name=DBInstanceIdentifier,Value=paging-reader-az1a --statistics Maximum --period 60 --start-time 2026-09-28T09:00:00Z --end-time 2026-09-28T10:00:00Z # 3. Installs with dropped duplicates, by library version, yesterday aws athena start-query-execution --work-group telemetry --query-string "SELECT lib_version, approx_distinct(install_id) AS installs FROM paging_daily WHERE day = '2026-09-27' AND dup_dropped > 0 GROUP BY lib_version ORDER BY installs DESC" # 4. Remote switch: deploy the flags version that turns velocity prefetch off aws appconfig start-deployment --application-id a1b2c3d --environment-id e4f5g6h --configuration-profile-id k7l8m9n --configuration-version 12 --deployment-strategy-id AppConfig.AllAtOnce # 5. Telemetry stream throttling aws cloudwatch get-metric-statistics --namespace AWS/Firehose --metric-name ThrottledRecords --dimensions Name=DeliveryStreamName,Value=paging-telemetry --statistics Sum --period 300 --start-time 2026-09-28T09:00:00Z --end-time 2026-09-28T10:00:00Z # 6. Scale the paging service aws ecs update-service --cluster paging --service paging-api --desired-count 120
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | A versioned contract with validation that contains a bad data source to one list; session expiry handled as a normal path; remote switches with safe defaults; releases checked against 20 apps; the Firehose quota checked before launch and the telemetry stream split in three REL 3 · REL 8 · REL 1 |
| Performance Efficiency | Memory budgets and maxSize per device class; frame tests on low-end devices every release; jank tracked per version PERF 5 · PERF 1 |
| Security | Ranking sessions bound to their owner and checked on every read; telemetry with no content or item IDs; the library published through a reviewed, signed pipeline SEC 3 · SEC 7 · SEC 11 |
| Cost Optimization | ≈ $71K a month for our backends; library defaults priced for 20 backends; one library instead of 20 copies COST 8 · COST 11 |
| Operational Excellence | Golden signals counted by distinct installs; a rollback procedure for code we can't recall; conformance and compatibility suites OPS 4 · OPS 6 · OPS 5 |
| Sustainability | Waste (pages fetched and never shown) measured per list type; conservative defaults; telemetry aggregated on the phone and sent once a day SUS 3 · SUS 6 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Separates what the library owns (caching, window, diff, states, prefetch) from what a team owns (a data source), and writes the contract's rules, not just its signature.
- Knows keyset's assumption (sort keys don't move) and uses ranking sessions where it breaks, with an expiry path that doesn't jump.
- Applies edits and deletes through the same database, guarded by versions and tombstones, so ordering of updates doesn't matter.
- Designs accessibility into the library's behaviour.
- Treats defaults as API and as capacity, and designs releases for code that can't be recalled.
- Builds telemetry that counts people, not events, with no content.
- Sizes session state, telemetry volume and quotas, and catches the Firehose throughput limit.
Follow-up questions
-
"The feed backend goes multi-region. What happens to cursors and ranking sessions?" Answer: keyset cursors hold values, not server state, so any region with the same data can serve them; the settle window must then also cover cross-region replication lag for any region that serves
newerreads, ornewerreads stay in the region that took the write. Ranking sessions are server state: either keep a user's session in their home region and route by it, or treat a failover like expiry, a410and a new session appended below. The second is simpler and already tested. -
"A team wants the list to jump to 'page 40' of search results." Answer: in network mode their data source can use offsets if the search API supports it, and the library's placeholders need a count: the contract's
items_beforeanditems_after. We'd support it for search because results are a frozen snapshot per query. We'd refuse it for live feeds, where "page 40" doesn't mean anything stable. -
"How would you know the settle window is long enough in production?" Answer: it isn't a fixed number we hope is long enough: each task computes it per request from the lag it measured a second ago, and moves head and
newerreads to the writer when lag is unknown or above 5 s. What we still verify is the commit side: the server logs, per write, the time fromcreated_atto commit, whichstatement_timeout(ortransaction_timeout) caps at 1 s. A nightly job can also look for posts whose commit came later than the cutoff allowed, and a refresh whose head page spans such a post repairs it on the phone.
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping: sort order, page size, inserts while scrolling, total size, devices | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements; the correctness rule; the API with an opaque cursor | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.5: offset → keyset, the index, recycling, prefetch sizing, off-main-thread work | Steps 2.1–2.6: database-mediated paging, the window, anchors and diffs, the generation fence, fling handling, stable IDs | Steps 3.1–3.6: the contract, ranking sessions, item updates, accessibility, stability, telemetry |
| 40–50 min | Numbers: requests, bytes, database, cost | Numbers: head checks, bytes, memory, fetch time, fleets, cost | Numbers: device classes, all teams' load, defaults as capacity, sessions, telemetry quotas, cost |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design: what's the sort order, can items be added or removed while someone scrolls, and what devices set the memory and frame budget?"
- When the scope is raised: "Here's what breaks, and I'll fix it in this order: anything that repeats or loses items, then anything that moves the list under the reader, then memory and frames, then bytes and cost."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the network fails mid-scroll?" (REL 5) | The retry repeats the same cursor, with full-jitter backoff and a server deadline shorter than the client's timeout. | 1–2 | R1.9, R2.8 |
| "What works offline?" (REL 11) | Everything saved: the list reads the phone's database, and only the append at the end of the saved run fails, as a footer. | 2 | Step 2.1 | |
| "How do you change the library without breaking teams?" (REL 3) | A small versioned contract, API dumps in CI, a conformance kit and remote switches with safe defaults. | 3 | Step 3.5 | |
| Performance | "Why is page 5,000 as fast as page 1?" (PERF 3) | A keyset cursor is an index seek, , not a scan of the rows before it. | 1 | Steps 1.1–1.2 |
| "How do you stay smooth at 120 Hz?" (PERF 1) | Nothing but binding on the main thread, diffs in the background, a bounded window and prefetch sized from fetch time and speed. | 2 | Steps 2.2–2.5 | |
| Security | "Can a client read someone else's list?" (SEC 3) | Cursors are signed and bound to a list, and ranking sessions to their owner, checked on every read. | 1–3 | R1.4, step 3.2 |
| "What does your telemetry collect?" (SEC 7) | Counts and timings per install per day, with no content and no item IDs. | 3 | Step 3.6 | |
| Cost | "Where does the money go?" (COST 8) | About $0.9K, $42.3K and $71K a month; data transfer is most of it, so defaults and prefetch decide the bill. | 1–3 | R1.7, R2.6, R3.6 |
| "Why not API Gateway or a CDN for the API?" (COST 5) | At 18B requests a month their per-request prices cost four times the ALB, and personalized pages can't be cached anyway. | 2 | R2.6 | |
| Operations | "How do you know paging is healthy?" (OPS 4) | Jank, time to first page, duplicate and gap detections and OOMs per version, counted by distinct installs. | 3 | Step 3.6, R3.9 |
| "How do you undo a bad release?" (OPS 6) | Switch the behaviour off remotely, pause app rollouts, and patch forward. | 3 | R3.9 | |
| Sustainability | "How do you avoid wasting data and battery?" (SUS 3) | No prefetch while stopped, cancelled loads for rows flown past, head checks only while visible, and small initial loads. | 2–3 | Step 2.5, R3.6 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Paging correctness | Keyset with a tie-breaker; offsets explained | Both directions, nearest first; settle window; merge by sort key; generation fence | A contract with rules (progress, unique IDs, frozen order); ranking sessions |
| Client architecture | Recycling, prefetch, off-main-thread work | Database as single source, window, placeholders, background diff, anchors | Library layers per platform; modes; validation; accessibility |
| Device limits | Frame budgets; cheap bind | Memory arithmetic with images as the budget; 120 Hz share; fetch-time chain | Budgets per device class; measured in the field |
| Server contract | Opaque, signed cursor; index | Both cursors per page; head check; limits; no counts | Session cursors, 410, update channel |
| Numbers | Requests, bytes, database, cost | Head-check rate, bytes, fleets with AZ loss, cross-AZ, cost | All teams' load, defaults as capacity, session memory, telemetry quotas, cost |
| Well-Architected trade-offs | Correctness over jump-to-page | Local storage for offline and stability; data for smoothness | Stability over flexibility; privacy over detail |
| Evolving under new scope | Builds from OFFSET one problem at a time | Opens with what repeats or loses items | Designs for teams, platforms and versions it doesn't control |