0) Problem Restatement
Design a to-do list service. Users create lists and tasks, then update, complete, delete and reorder them. Two versions of this appear in interviews:
- Microsoft: a clean backend with CRUD APIs (Create, Read, Update, Delete), plus authentication, rate limiting, caching and API versioning.
- Roblox: a shared list where several people edit at the same time and see changes quickly, with sensible behavior for conflicting edits and offline changes.
1) Requirements
1.1 Functional
- Lists: create, rename, delete and share with others.
- Tasks: create, edit (title, due date, notes), complete or uncomplete, delete, and reorder.
- See changes from collaborators in near real time.
1.2 Non-Functional
- Simple, predictable APIs.
- Secure: users see only their own or shared lists.
- Fast (under 100 ms reads), highly available.
- Shared edits converge. Nobody's change silently disappears.
1.3 Scale Estimates
- 10M users, 100M tasks. Reads: 5K/sec, writes: 500/sec. This is modest scale, so correctness and API design matter most.
2) API Design (REST)
POST /v1/lists { name } → 201 { list }
GET /v1/lists?cursor=&limit=50 → 200 { items, next_cursor }
GET /v1/lists/{list_id}/tasks?status=open&cursor=
POST /v1/lists/{list_id}/tasks { title, due_at?, after_task_id? } → 201 { task }
PATCH /v1/tasks/{task_id} { title?, completed?, due_at? } (header If-Match: "v7")
POST /v1/tasks/{task_id}/move { after_task_id }
DELETE /v1/tasks/{task_id} → 204
POST /v1/lists/{list_id}/members { user_id, role: editor|viewer }
Good practices to mention:
- Nouns for resources and HTTP verbs for actions. Use PATCH for partial updates.
- Status codes: 201 created, 204 no content, 400 bad input, 401 not logged in, 403 not allowed, 404 not found, 409 conflict, 412 precondition failed (stale ETag), 429 too many requests.
- Cursor pagination instead of page numbers (stable when items are added).
- Idempotency-Key header on POST, so a retried create doesn't make two tasks.
- Versioning: put the version in the URL (
/v1/), keep v1 working while v2 exists, only add fields in minor changes, and announce deprecations early.
3) High-Level Architecture
Architecture Diagram
flowchart LR
C["Web / Mobile"] --> GW["API Gateway - auth, rate limit"]
GW --> API["Todo Service"]
API --> DB[("Postgres - lists, tasks")]
API --> CA[("Redis cache")]
API --> PS[("Pub/Sub - channel per list")]
PS --> WS["WebSocket servers"]
WS --> C- API Gateway: checks the auth token (OAuth/JWT) and applies rate limits per user (e.g., 100 requests/min).
- Todo Service: stateless and horizontally scalable.
- Postgres: lists, tasks and memberships. Every query filters by lists the user can access.
- Redis: caches list contents (invalidated on writes).
- Pub/Sub + WebSockets: push changes to everyone viewing a shared list.
4) Data Model
CREATE TABLE lists (list_id UUID PRIMARY KEY, owner_id UUID, name TEXT, updated_at TIMESTAMP);
CREATE TABLE list_members (list_id UUID, user_id UUID, role TEXT, PRIMARY KEY (list_id, user_id));
CREATE TABLE tasks (
task_id UUID PRIMARY KEY, list_id UUID, title TEXT, notes TEXT,
completed BOOLEAN DEFAULT FALSE, due_at TIMESTAMP,
rank TEXT, -- fractional index for ordering
version INT DEFAULT 1, -- for optimistic concurrency (ETag)
updated_by UUID, updated_at TIMESTAMP, deleted BOOLEAN DEFAULT FALSE
);
CREATE INDEX ON tasks (list_id, rank);
5) Deep Dive A — Concurrent edits
- Optimistic concurrency with ETags: a GET returns
ETag: "v7". A PATCH sendsIf-Match: "v7". If someone else changed the task (now v8), the server returns 412, and the client refreshes and retries or shows a merge prompt. - Field-level merging is friendlier: if A changes the title and B marks it complete, both changes can apply, since they touch different fields. Only same-field edits conflict, and then last write wins with a notice.
- Reordering: store a fractional
rank(see the playlist design). A move updates only one row, so concurrent moves of different tasks never conflict. - Deletes: soft delete (
deleted = true) for a short time, so undo works and offline clients can see that the task was deleted.
6) Deep Dive B — Real-time sync and offline
- Every successful write publishes
{ list_id, task, version }to the list's channel. Clients viewing that list apply it. Clients ignore events older than the version they already have. - Reconnect: the client sends its last seen change timestamp or sequence number, and the server returns changes since then (
GET /v1/lists/{id}/changes?since=...). - Offline: the app queues edits locally (with task IDs created on the client as UUIDs) and replays them on reconnect. Conflicts are handled as above.
7) Caching and Rate Limiting
- Cache a list's tasks in Redis by
list_id, and delete the cache key on any write to that list. - Use HTTP caching for GETs with ETags (
If-None-Match→ 304 Not Modified) to save bandwidth. - Rate limit per user and per IP at the gateway (token bucket). Return 429 with
Retry-After.
8) Wrap-Up
Design resource-based REST APIs with proper status codes, cursor pagination, idempotency keys and URL versioning, behind a gateway that handles auth and rate limits. Store tasks in Postgres with a fractional rank for ordering and a version for optimistic concurrency (ETags), and cache per list. For shared lists, publish every change to a per-list channel pushed over WebSockets, and let clients catch up with a "changes since" API after reconnecting or working offline.