Problem RestatementProblem
Amazon asked: design an e-book reader service with APIs, a database schema and an architecture. A user buys books, reads offline, and uses several devices (phone, tablet, e-reader) for the same book. The system must sync reading position, bookmarks, highlights and notes across devices, even when devices were offline and made changes at the same time.
RequirementsRequirements
- Library of owned books, and download of book files (DRM-protected).
- Read fully offline.
- Sync position ("page 142 on your phone, jump there?"), bookmarks, highlights and notes.
- Resolve conflicts from offline edits sensibly.
ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
D1["Phone"] -->|"sync changes"| SYNC["Sync Service"]
D2["E-reader"] --> SYNC
SYNC --> DB[("Reading state + annotations DB")]
D1 -->|"download"| CDN["CDN - encrypted book files"]
LIC["License / DRM service"] --> D1
STORE["Store / purchases"] --> LIB[("Library - entitlements")]
SYNC --> LIB
SYNC -->|"push: new position"| D2Data ModelData model
books: book_id, title, authors, format, file_versions
library: user_id, book_id, acquired_at, license_status
positions: user_id, book_id, device_id, location (e.g. EPUB CFI / percent), updated_at
annotations: annotation_id (UUID from device), user_id, book_id, type (bookmark|highlight|note),
range_start, range_end, text, color, updated_at, deleted (tombstone), versionLocations use a stable format that doesn't depend on font size (e.g., an EPUB CFI or a character offset), since "page 142" differs per device.
Deep Dive — One book, three devices, no connectionDeep dive
Someone reads on a phone on the train, then opens a tablet at home. Both were offline, both made changes. Neither should lose anything.
Last write wins on the whole reading state
Each device uploads its state; the newest upload replaces the stored one.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
PH["Phone - page 212, added 3 highlights"] --> UP1["Uploads"]
TB["Tablet - page 96, added 1 note"] --> UP2["Uploads later"]
UP2 --> OVER["Tablet's state overwrites"]
OVER --> LOST["3 highlights gone, position jumps backwards to 96"]Reading position and annotations are bundled into one blob, so a device that is merely behind erases work from a device that was ahead. Annotations are the painful loss — they are the thing readers actually create.
Compare timestamps per field
Store a timestamp with the position and with each annotation, and keep the newer one.
Annotations stop destroying each other, which is the important fix. Position is still wrong: "newer" is not the same as "further". A reader who opened the tablet briefly to check a reference has the most recent position and the least progress, so the phone's page 212 is replaced by page 96.
A change log per device, and different rules for different data
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
DEV["Device - all changes recorded in a local log"] --> SYNC["POST /sync {since_cursor, changes[]}"]
SYNC --> SRV["Server applies changes"]
SRV --> BACK["Returns changes from other devices since the cursor + a new cursor"]
SRV --> POS["Reading position - stored PER DEVICE"]
POS --> FUR["On open: if another device is further ahead, offer 'Jump to page 212?'"]
SRV --> ANN["Annotations - merged by id, edits by timestamp"]
ANN --> TOMB["Deletes are tombstones, so an offline device learns about them"]- Position is per device, and the user decides. Keeping each device's own position and offering the furthest one — Kindle's behaviour — is better than any automatic rule, because only the reader knows whether page 96 was a detour.
- Annotations merge by id. They are independent objects, so additions from different devices simply coexist; only edits to the same annotation need a timestamp comparison.
- Deletes need tombstones. Without one, a device that was offline when a highlight was deleted re-uploads it and resurrects it on every other device.
- A cursor makes sync incremental and resumable. The device says what it last saw, the server returns only what changed, and an interrupted sync resumes rather than restarting.
The general rule worth stating: sync conflicts are resolved per data type, not per record. Position wants "furthest", annotations want "union", settings want "most recent" — one policy for all three is guaranteed to be wrong for two of them.
Downloads and DRM
- Book files are encrypted and served by a CDN. The device gets a license (a key bound to the device and account) from the license service. A device limit per book or account applies.
- Returning or refunding a book revokes the license at the next sync.
Wrap-UpWrap-up
Store the library, per-device reading positions (in font-independent locations) and annotations (client-generated IDs, versions, tombstones) on the server. Devices work offline and sync by sending their local change log and pulling changes since a cursor, with simple conflict rules: offer a jump to the furthest or most recent position, last-write-wins for edited annotations, and tombstones for deletes. Deliver encrypted books through a CDN with device-bound licenses.