Design Mobile Paging & Infinite Scrolling Library Architecture
1. Problem Statement & Scope
System Mission
Design a memory-bounded, flicker-free, bidirectional mobile pagination and prefetching library architecture capable of rendering feeds with millions of items at a silky 120 FPS ( render budget) on low-RAM devices (). The architecture orchestrates a 3-tier caching hierarchy (L1 In-Memory LRU L2 SQLite WAL Disk L3 Cloud API Network), opaque cursor-based keyset pagination, asynchronous Myers list diffing, dynamic fling-velocity prefetch heuristics, and offscreen page eviction.
Keyset Seek Invariant: Keyset cursor pagination replaces unbounded SQL OFFSET scans (which cost ) with indexed composite tuple seeks (created_at, item_id) < (:ts, :id). A B-tree descent is in the table size and independent of how deep the user has scrolled, so it stays at () in Aurora PostgreSQL buffer pools regardless of whether the user is on page 1 or page 10,000, completely preventing database buffer pool eviction and timeline duplication anomalies.
Synthesizing vector architecture diagram...
Functional Requirements
-
Opaque Keyset Cursor Pagination: Seek sequential pages using opaque base64 cursor tokens containing composite sort keys
(timestamp_epoch_ms, item_id), completely eliminating skipped or duplicated records during real-time timeline mutations. -
Bidirectional Scrolling Support: Seamlessly support bidirectional pagination:
- Append: Infinite scrolling downward to fetch older historical items.
- Prepend: Infinite scrolling upward to fetch newer unread items.
- Refresh: Pull-to-refresh to fetch head snapshots without clearing existing view state.
-
3-Tier Caching Architecture:
-
Dynamic Prefetch Distance Heuristics: Dynamically compute prefetch windows based on flinging scroll velocity: prefetch 3 pages ahead during rapid fling (), 1 page during steady reading, and pause prefetching during stationary pauses.
-
Strict Memory Bounding & Viewport Eviction: Bound loaded items in memory (
max_size = 200 items); automatically drop distant offscreen pages from RAM while preserving scroll position via placeholder sizing, maintaining heap usage under on devices with total RAM.
Non-Functional Requirements (SLAs/SLOs)
- UI Smoothness & Frame Budget: Strict 120 FPS ( per frame); zero main-thread layout thrashing, JSON decoding, or diff calculations.
- Client Heap Footprint: Total memory allocated to paging bounded to regardless of whether the user scrolls through 50 or 50,000 items.
- Backend Keyset Seek Latency: B-Tree index seek latency () in Amazon Aurora PostgreSQL at 20,000 QPS.
- Network Bandwidth Efficiency: Compressed page payloads per 20-item page; intermediate in-flight requests cancelled during high-speed flings.
- CAP / PACELC Classification: Local database operates as an AP node prioritizing read Availability and low Latency (PA/EL). Backend Aurora PostgreSQL enforces strong consistency for keyset orderings.
Out-of-Scope
- Virtual list rendering for web HTML DOM tree optimizations (e.g.,
react-windowDOM virtualization). - Server-side full-text lexical ranking and BM25 relevance scoring.
2. Capacity & Scale Estimation
Device, Memory & Traffic Math
- Active Mobile Traders / Readers: 50 Million Daily Active Users (DAU).
- Daily Scrolling Volume: Each active user requests an average of 12 pages per day ().
- Average & Peak Paging QPS:
- Network Bandwidth Throughput: Average compressed page response (20 items with author metadata, thumbnails) .
3-Tier Mobile Memory Footprint Sizing
- L1 In-Memory Cache (LRU Viewport Window):
- Max loaded data models: .
- Decoded Image Bitmap LRU Cache: Capped at 20 visible/cached screen items .
- Viewholder / Diff state metadata: .
- Total Paging RAM Budget: (Safely below the threshold for low-RAM devices with system RAM).
- L2 Local SQLite WAL Disk Cache:
- Max cached historical records: .
- WAL journal size: Capped at .
- L3 Cloud Backend Storage (3-Year Horizon):
- 500 Million total feed/catalog items.
- Record size in PostgreSQL .
- Amazon Aurora 6-way Multi-AZ storage (physical; Aurora bills the logical 1.0 TB): .
- Composite B-Tree index
(created_at DESC, item_id DESC): .
Fleet Sizing & Compute Infrastructure
- Amazon Aurora PostgreSQL Cluster:
Provision
db.r6g.8xlarge(32 vCPU, 256 GB RAM) primary instance. The entire 32 GB B-Tree index resides resident in the PostgreSQL buffer pool (shared_buffers), ensuring all cursor queries execute as in-memory B-tree descents (, ~30 page touches for 500M rows). - Stateless Paging Service (ECS Fargate): Peak 21,000 QPS. Each container task handles 350 req/sec .
3. AWS-First High-Level Architecture
Synthesizing vector architecture diagram...
Follow a feed request. The newest page (/feed/head) is cached at CloudFront for 10 s, so millions of app opens hit the edge rather than the backend. Older pages carry a cursor and go through API Gateway to the paging service in the "Stateless Paging Service Tier" panel, which serves hot timelines from Redis and otherwise runs a keyset query (WHERE id < cursor ORDER BY id LIMIT 20) on Aurora replicas, which stays fast at any depth, unlike OFFSET. On the phone, in the "Mobile 3-Tier Caching Engine Deep Dive" panel, the list view shows items from an in-memory window of up to 200 items; the page fetcher fills that window from local SQLite and SQLite from the network, while a background diff (Myers) applies only the changed rows to the list. Each tier absorbs most requests for the tier below it, so scrolling rarely waits on the network.
Data Flow Walkthrough
- Viewport Render (L1 Cache Hit): As the user scrolls, the UI list view requests items by index. If the item exists in the L1 in-memory window, it binds to the viewholder instantly () with zero disk or network I/O.
- Boundary Prefetch Trigger: As the user scrolls within the prefetch distance threshold ( items from the edge), the
RemoteMediatorcalculates current scroll velocity . - Local Database Check (L2 Cache Hit):
RemoteMediatorqueries local SQLite. If the next page is already cached, it loads into memory, emits a new snapshot, and Myers diffing animates the insertion at 120 FPS. - Cloud Network Fetch (L3 Cache Miss): If local SQLite has reached the end of cached data,
RemoteMediatordispatches an asynchronous HTTP/2 keyset request to Amazon CloudFront, routed via API Gateway to the ECS Paging Service. - Keyset B-Tree Seek & Generation Guard: Aurora PostgreSQL executes an composite index seek:
WHERE (created_at, item_id) < (:cursor_ts, :cursor_id) ORDER BY created_at DESC, item_id DESC LIMIT 20. The 20 items write directly to local SQLite guarded by the active generation token (generation_id), triggering reactive Myers diffing without flickering or layout jitter.
End-to-End Request Tracing Walkthrough
| Step # | Event / Action | Component State | Distributed Transition | Output / Response |
|---|---|---|---|---|
| Step 1 | User scrolls near list boundary | Viewport scrolling | Viewport observer detects items remaining in loaded direction | Prefetch threshold event dispatched to RemoteMediator |
| Step 2 | Velocity calculation & threshold | Prefetcher active | Calculates ; evaluates steady () vs fling | Prefetch window size resolved (1 page steady, 3 pages fling) |
| Step 3 | Local L2 database keyset lookup | Local DB seek | RemoteMediator seeks next page from local_feed_items via composite index | Cache miss: end-of-local-cache reached; prepares cloud fetch |
| Step 4 | Opaque HMAC keyset token build | Network dispatch | Extracts cursor (timestamp, item_id, hmac) from local_page_keys | HTTP/2 GET /v1/feed/items?cursor=... dispatched |
| Step 5 | CloudFront edge & API Gateway routing | Ingress routing | CloudFront checks edge cache; passes miss to API Gateway and ECS Fargate | Request arrives at Paging Service resolver () |
| Step 6 | Aurora PostgreSQL keyset index seek | Database seek | Executes seek: WHERE (created_at, item_id) < (...) LIMIT 20 | B-Tree index seek completes in in shared_buffers |
| Step 7 | Compressed payload returned | Wire transit | Cloud backend returns 20 items + next/prev HMAC cursors in 25 KB payload | Client receives HTTP 200 with new cursor tokens |
| Step 8 | Generation-fenced atomic commit | SQLite transaction | BEGIN EXCLUSIVE TRANSACTION: inserts 20 items iff generation_id == current | Stale in-flight appends fenced; local DB emits change snapshot |
| Step 9 | Background Myers diffing calculation | Diff computation | AsyncListDiffer computes minimal insertion diff on background thread | Changeset resolved with zero main-thread blockage |
| Step 10 | Granular 120 FPS UI range insertion | UI render loop | Dispatches notifyItemRangeInserted; evicts distant pages if RAM items | Items render smoothly at 120 FPS; heap bounded under 30 MB |
4. API Interface Design & Wire Protocols
1. Keyset Cursor Pagination Endpoint (GET /v1/feed/items)
httpGET /v1/feed/items?limit=20&cursor=eyJjcmVhdGVkX2F0IjoxNzczNjQ4MDAwMDAwLCJpdGVtX2lkIjoiaXRlbV84OGE5MWM3NCIsInNpZ25hdHVyZSI6IjBhZjkyYy4uLiJ9 HTTP/1.1 Host: api.feed.production.aws.internal Authorization: Bearer <jwt_access_token> Accept: application/json Accept-Encoding: gzip, br X-Client-Velocity-PxSec: 850
Opaque Cursor Format (Base64 Encoded JSON with HMAC-SHA256 Integrity Tag)
json{ "created_at": 1773648000000123, "item_id": "item_88a91c74", "signature": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" }
The cursor must carry created_at at exactly the precision the database column stores. PostgreSQL timestamptz has microsecond precision; a cursor truncated to milliseconds re-introduces the very skip/duplicate bug keyset pagination exists to fix (rows created within the same millisecond but after the truncated value are silently skipped). The cursor above is epoch microseconds; the mobile L2 cache stores the same value in created_at_epoch_us.
Response: 200 OK
json{ "items": [ { "id": "item_88a91c75", "title": "Scaling Mobile Paging to 120 FPS", "author_id": "usr_998124", "author_name": "Staff Mobile Architect", "thumbnail_url": "https://cdn.feed.com/thumb/88a91c75.webp", "created_at_epoch_us": 1773647998200456 } ], "page_info": { "page_size": 20, "has_next_page": true, "has_previous_page": true, "next_cursor": "eyJjcmVhdGVkX2F0IjoxNzczNjQ3OTYwMDAwLCJpdGVtX2lkIjoiaXRlbV85OTgxMjRhOSIsInNpZ25hdHVyZSI6IjljYTIxYi4uLiJ9", "prev_cursor": "eyJjcmVhdGVkX2F0IjoxNzczNjQ4MDIwMDAwLCJpdGVtX2lkIjoiaXRlbV83N2EyMWJjMSIsInNpZ25hdHVyZSI6IjFhZjgyYi4uLiJ9" } }
2. Status Codes & Error Contracts
| HTTP Status | Reason String | Error Contract Payload | Client Handling / Recovery Strategy |
|---|---|---|---|
200 OK | PAGE_SUCCESS | Standard items list + page_info | Insert items into SQLite; advance page keys |
400 Bad Req | INVALID_CURSOR | {"error": "Cursor signature tampered or corrupt"} | Discard cursor; fall back to head page refresh |
401 Unauth | TOKEN_EXPIRED | {"error": "Access token expired"} | Refresh OAuth token; replay the same cursor (a deleted anchor item is not an error: the tuple comparison is against values, not rows, so the seek still lands correctly) |
429 Too Many | PAGING_THROTTLED | {"error": "Exceeded 10 pages/sec fling rate"} | Cancel intermediate flinging prefetch tasks |
503 Service | AURORA_DEGRADED | {"error": "Database read replica pool saturated"} | Set LoadState.Error; display inline "Tap to Retry" button |
5. Data Models & Storage Architecture
Local SQLite Schema with WAL Mode (mobile_paging_store.db)
sql-- Feed Items Table (L2 Disk Cache with Generation Fencing) CREATE TABLE local_feed_items ( item_id TEXT PRIMARY KEY NOT NULL, title TEXT NOT NULL, author_id TEXT NOT NULL, author_name TEXT NOT NULL, thumbnail_url TEXT, created_at_epoch_us INTEGER NOT NULL, generation_id INTEGER NOT NULL DEFAULT 1, -- Fences writes from superseded refresh cycles cached_at INTEGER NOT NULL ); -- Composite Index enabling instant local keyset seeks CREATE INDEX idx_local_keyset ON local_feed_items(generation_id, created_at_epoch_us DESC, item_id DESC); -- Page Keys Table (Bidirectional Cursor Tracking with Generation Fencing) CREATE TABLE local_page_keys ( item_id TEXT PRIMARY KEY NOT NULL, prev_cursor TEXT, next_cursor TEXT, generation_id INTEGER NOT NULL DEFAULT 1, -- Prevents stale append/prepend overwrites updated_at INTEGER NOT NULL, FOREIGN KEY (item_id) REFERENCES local_feed_items(item_id) ON DELETE CASCADE ); CREATE INDEX idx_page_keys ON local_page_keys(generation_id, item_id); -- CursorWindow Memory Hygiene & Scoped Statement Finalization: -- 1. Explicit projection of visible columns only (never SELECT * containing large content blobs). -- 2. Deterministic try-with-resources / Kotlin use { cursor -> ... } statement scoping. -- 3. Eliminates native file descriptor leaks and 2MB CursorWindowAllocationException crashes.
Backend PostgreSQL Keyset Schema & Composite B-Tree Index (production_feed_db)
sqlCREATE TABLE feed_items ( item_id VARCHAR(64) PRIMARY KEY, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, author_id VARCHAR(64) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL, is_deleted BOOLEAN NOT NULL DEFAULT FALSE ); -- Crucial: Composite B-Tree index matches exact ORDER BY and WHERE tuple syntax CREATE INDEX idx_feed_keyset_pagination ON feed_items (created_at DESC, item_id DESC) WHERE is_deleted = FALSE; -- partial index: the AND is_deleted = FALSE predicate must be covered or the seek degrades to a filter scan -- O(1) Keyset Query Seeking Page 10,000 in < 1.5ms: SELECT item_id, title, content, author_id, created_at FROM feed_items WHERE (created_at, item_id) < ('2026-09-16 12:00:00+00', 'item_88a91c74') AND is_deleted = FALSE ORDER BY created_at DESC, item_id DESC LIMIT 20;
Unlock Complete Architecture & Production Runbooks
You have explored the free architectural preview (~40%). Spend 1 Coin to unlock the remaining 6 production deep-dive sections for a full 24 hours.