02 - Design a To-Do List App
📋 Jump to Takeaways🎁 A to-do app sounds trivial until you need it to sync across devices, survive server crashes, and handle 10 million users. What does that take?
A To-Do List app (like Todoist or Microsoft To Do) lets users create, update, complete, and delete tasks. It's a CRUD-heavy system that introduces API design, relational modeling, and the basics of multi-user data isolation. Simple enough to fit in one lesson, complex enough to teach real patterns.
To-Do List Requirements
To-Do List Functional Requirements
- Create, read, update, delete tasks
- Mark tasks as complete/incomplete
- Organize tasks into lists (e.g., "Work", "Personal")
- Set due dates and priorities
- Each user sees only their own tasks
To-Do List Non-Functional Requirements
| Metric | Target |
|---|---|
| Users | 5 million registered, 500K DAU |
| Tasks per user | Average 50 active, up to 1,000 |
| Read:Write ratio | 2:1 (users check their list often but also create, complete, reorder frequently) |
| Latency | < 100ms for reads, < 300ms for writes |
| Availability | 99.9% (~8.7 hrs/year, ~43 min/month, ~86 sec/day) |
| Durability | Zero data loss. Losing someone's tasks is unacceptable |
To-Do List Scope Exclusions
- Real-time collaboration (multiple users editing the same shared list simultaneously)
- File attachments
- Recurring tasks (simplifies the data model)
- Offline sync (mobile-first concern, not backend architecture)
To-Do List Estimation
To-Do List Traffic
- 500K DAU × 20 reads/day = 10M reads/day
- 500K DAU × 10 writes/day (create, complete, reorder, edit) = 5M writes/day
- Read QPS: 10M / 86,400 ≈ 115 reads/sec
- Write QPS: 5M / 86,400 ≈ 58 writes/sec
Low QPS. A single PostgreSQL instance handles this easily.
To-Do List Storage
- 5M users × 50 tasks × 500 bytes/task = 125 GB total
- Growth: ~500K new tasks/day × 500 bytes = 250 MB/day (most writes are updates, not creates)
To-Do List Key Insight
This system is neither compute-bound nor storage-bound. The challenge is correct API design, data modeling, and multi-tenant isolation, not scale. This is typical for most real-world applications.
To-Do List High-Level Design
┌─────────────────────────────────────────────────────────────┐
│ TO-DO LIST APP │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────────────┐ ┌────────────┐ │
│ │ Client │────►│ API Server │────►│ PostgreSQL │ │
│ │ (Web/ │◄────│ │◄────│ │ │
│ │ Mobile)│ │ ┌────────────┐ │ │ users │ │
│ └──────────┘ │ │ Auth │ │ │ lists │ │
│ │ │ middleware │ │ │ tasks │ │
│ │ │ (JWT) │ │ └────────────┘ │
│ │ └────────────┘ │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘Why so simple? Because it should be. At 115 reads/sec and 125 GB total data, a single PostgreSQL instance with an API server is the correct architecture. Over-engineering this with microservices, message queues, or caching would be wrong.
When would this need to grow?
- 100× users → add read replicas
- Real-time sync → add WebSockets
- Shared lists → add permission model + conflict resolution
To-Do List Deep Dive
To-Do List Data Model
users
├── id (UUID, primary key)
├── email (unique)
├── password_hash
└── created_at
lists
├── id (UUID, primary key)
├── user_id (FK → users) ← tenant isolation
├── name ("Work", "Personal")
├── position (integer, for ordering)
└── created_at
tasks
├── id (UUID, primary key)
├── list_id (FK → lists)
├── user_id (FK → users) ← denormalized for fast queries
├── title
├── description (nullable)
├── completed (boolean, default false)
├── priority (enum: low, medium, high)
├── due_date (nullable)
├── position (integer, for ordering)
├── created_at
└── updated_atWhy user_id on both lists and tasks? Denormalization. Without it, fetching "all tasks for user X" requires a JOIN through lists. With it, one indexed query: WHERE user_id = ? AND completed = false.
To-Do List Indexes
The data model is useless without proper indexes for your query patterns:
-- "Show me all incomplete tasks for this user"
CREATE INDEX idx_tasks_user_incomplete ON tasks(user_id, completed) WHERE completed = false;
-- "Show me tasks in this list, ordered"
CREATE INDEX idx_tasks_list_position ON tasks(list_id, position);
-- "Show me all lists for this user, ordered"
CREATE INDEX idx_lists_user_position ON lists(user_id, position);Without these, PostgreSQL does full table scans. At 125 GB, that's unacceptable even at low QPS.
To-Do List REST API
Authentication: Bearer token in Authorization header
Lists:
GET /api/lists → all lists for current user
POST /api/lists → create list
PATCH /api/lists/:id → rename, reorder
DELETE /api/lists/:id → cascade-delete list + all its tasks
Tasks:
GET /api/lists/:id/tasks → tasks in a list
POST /api/lists/:id/tasks → create task in a list
PATCH /api/tasks/:id → update title, complete, priority, due_date
DELETE /api/tasks/:id → delete task
Bulk:
PATCH /api/tasks/reorder → { task_ids: [...] } update positionsWhy PATCH instead of PUT? PATCH updates only the fields you send. PUT replaces the entire resource. For toggling completed, you don't want to resend the entire task object.
Why is PATCH at /api/tasks/:id instead of /api/lists/:id/tasks/:id? Once a task exists, you operate on it directly by its own ID. Nesting under lists for creation makes the relationship clear, but deep nesting for updates adds nothing. The task ID is already globally unique.
What does DELETE /api/lists/:id do? Cascade-deletes all tasks in that list within a transaction. At 50 tasks per list, this is instant. If lists could have 10,000+ tasks, you'd delete in batches to avoid long-running transactions.
Multi-Tenant Isolation
Every query must be scoped to the authenticated user:
-- CORRECT: always filter by user_id
SELECT * FROM tasks WHERE user_id = :current_user AND list_id = :list_id;
-- DANGEROUS: trusting the URL parameter alone
SELECT * FROM tasks WHERE list_id = :list_id;
-- ↑ User A could access User B's list by guessing the list_idRule: Never trust resource IDs from the client alone. Always verify ownership with user_id = :current_user in the WHERE clause.
Task Ordering Strategies
Users drag tasks to reorder them. How to store order?
| Approach | Pros | Cons |
|---|---|---|
Integer position column |
Simple, fast reads (ORDER BY position) | Reorder requires updating many rows |
| Fractional positions (1.0, 1.5, 2.0) | Insert between without updating others | Precision issues after many inserts |
| Linked list (each task points to next) | Insert/move is O(1) writes | Fetching ordered list requires traversal |
Best for a To-Do app: Integer position with batch update.
When user reorders, the client sends the new order of all task IDs in that list. Server updates positions in one transaction:
BEGIN;
UPDATE tasks SET position = 0 WHERE id = 'task-c';
UPDATE tasks SET position = 1 WHERE id = 'task-a';
UPDATE tasks SET position = 2 WHERE id = 'task-b';
COMMIT;At 50 tasks per list, updating all positions is fast. This approach breaks down at 10,000+ items, but that's not our use case.
JWT vs Session Authentication
Two common approaches:
| Approach | How it works | Tradeoff |
|---|---|---|
| Session-based | Server stores session in DB/Redis, client sends cookie | Stateful, need shared session store if multiple servers |
| JWT (token-based) | Server signs a token, client sends in header | Stateless, but can't revoke tokens until they expire |
For a To-Do app: Either works. JWT is simpler for mobile clients (no cookies). Add a short expiry (15 min) with refresh tokens for security.
Soft Delete vs Hard Delete
When a user deletes a task, do you actually remove the row?
- Hard delete:
DELETE FROM tasks WHERE id = ?. Gone forever. - Soft delete:
UPDATE tasks SET deleted_at = NOW() WHERE id = ?. Hidden but recoverable.
For a To-Do app: Hard delete is fine. Tasks are low-value, user-created content. No compliance requirement to retain them. Soft delete adds complexity (every query needs WHERE deleted_at IS NULL) for little benefit.
Exception: if you add an "undo" feature or trash folder, use soft delete with a 30-day cleanup job.
Key Takeaways
Don't over-engineer simple systems
At 115 reads/sec and 125 GB data, a single PostgreSQL instance is the correct answer. Adding Redis, Kafka, or microservices here would be wrong. Know when simplicity is the right architecture.
Multi-tenant isolation is a security requirement, not an optimization
Every database query must include
user_id = :current_user. Trusting client-provided resource IDs without ownership verification is a data leak vulnerability.Denormalize for your primary access pattern
Adding
user_idto the tasks table avoids a JOIN on every read. The cost (slight write overhead, data duplication) is worth it when reads are your primary access pattern.RESTful API design has conventions, so follow them
Use nouns for resources, HTTP verbs for actions, PATCH for partial updates, proper status codes. Consistent APIs are easier to build clients for and easier to extend later.
Integer positions with batch update work for small ordered lists
Drag-and-drop reordering at 50 items per list is trivial with integer positions. Don't reach for fractional indexing or linked lists until you have thousands of items.
Choose the simplest auth that fits your client
JWT for mobile/SPA (stateless, no cookies). Sessions for server-rendered apps (simpler, revocable). Both are valid. Pick based on your client architecture, not hype.
🎁 What if you only have 7 characters to represent billions of unique URLs, and every microsecond of latency on redirect matters?