Let's design for 10 million Monthly Active Users (MAU) with 2 million Daily Active Users (DAU)
If each user does ~30 requests/day (logging exercises, checking progress, etc.):
Storage:
Assuming 20 requests are about logging exercises
5-year projection would be ≈ 25 TB *
POST /usersCreate a new user.
Body
{
"name": "string",
"email": "string"
}
Status codes
| Code Meaning | |
| 201 | Created successfully |
| 400 | Invalid body (missing/malformed fields) |
| 409 | Email or name already exists |
| 500 | Server error |
GET /users/{id}Fetch a single user.
Response 200
{
"id": 0,
"name": "string",
"email": "string"
}
Status codes
| Code Meaning | |
| 200 | Returns the user |
| 404 | User not found |
| 500 | Server error |
PUT /users/{id}Replace a user's data.
Body
{
"name": "string",
"email": "string"
}
Response 200 — returns the updated user (same shape as GET /users/{id})
Status codes
| Code Meaning | |
| 200 | Returns the updated user |
| 400 | Invalid body |
| 403 | Not authorized |
| 404 | User not found |
| 500 | Server error |
GET /users/{id}/achievementsQuery parameters
| Param Type Description | ||
from | timestamp | Start of date range |
to | timestamp | End of date range |
term | string | Search achievements by name |
page | integer | Page number |
limit | integer | Page size |
Response 200
{
"achievements": [
{
"name": "string",
"description": "string",
"startAt": "ISO timestamp",
"endAt": "ISO timestamp",
"progress": 0
}
],
"page": 0,
"total": 0
}
Status codes
| Code Meaning | |
| 200 | Success |
| 404 | User not found |
| 500 | Server error |
GET /users/{id}/statsQuery parameters
| Param Type Description | ||
from | timestamp | Start of date range |
to | timestamp | End of date range |
Response 200
{
"exercises": 0
//...
}
Status codes
| Code Meaning | |
| 200 | Success |
| 404 | User not found |
| 500 | Server error |
GET /activitiesQuery parameters
| Param Type Description | ||
from | timestamp | Start of date range |
to | timestamp | End of date range |
term | string | Search activities by name |
page | integer | Page number |
limit | integer | Page size |
Response 200
{
"activities": [
{
"id": 0,
"name": "string",
"loggedAt": "timestamp",
"type": "RUNNING | SWIMMING | ...",
"exercises": 0
}
],
"page": 0,
"total": 0
}
Status codes
| Code Meaning | |
| 200 | Success |
| 500 | Server error |
POST /activitiesBody
{
"name": "string",
"loggedAt": "timestamp",
"type": "RUNNING | SWIMMING | ..."
}
Status codes
| Code Meaning | |
| 201 | Success |
| 400 | Invalid timestamp format, type, etc. |
| 500 | Server error |
GET /activities/{id}Response 200
{
"id": 0,
"name": "string",
"loggedAt": "timestamp",
"type": "RUNNING | SWIMMING | ...",
"exercises": [
{
"name": "string",
"attributes": {
"key": "value"
}
}
],
"attributes": {
"key": "string | number | bool"
}
}
Status codes
| Code Meaning | |
| 200 | Success |
| 404 | Activity not found |
| 500 | Server error |
PATCH /activities/{id}All fields optional; only defined fields are updated.
Body
{
"name": "string",
"loggedAt": "timestamp",
"type": "RUNNING | SWIMMING | ...",
"exercises": [
{
"name": "string",
"attributes": {
"key": "value"
}
}
],
"attributes": {
"key": "string | number | bool"
}
}
Status codes
| Code Meaning | |
| 200 | Returns the updated activity |
| 400 | Invalid body |
| 404 | Activity not found |
| 500 | Server error |
DELETE /activities/{id}Status codes
| Code Meaning | |
| 204 | Deleted successfully (no content) |
| 404 | Activity not found |
| 500 | Server error |
Note: the original draft listed201for delete —204 No Contentis the conventional code for a successful delete with no response body.
POST /activities/{id}/exercisesBody
{
"name": "string",
"attributes": {
"key": "value"
}
}
Status codes
| Code Meaning | |
| 201 | Created successfully, returns the exercise |
| 400 | Invalid body |
| 404 | Activity not found |
| 500 | Server error |
PUT /activities/{id}/exercises/{eid}Body
{
"name": "string",
"attributes": {
"key": "value"
}
}
Status codes
| Code Meaning | |
| 200 | Returns the updated exercise |
| 400 | Invalid body |
| 404 | Activity or exercise not found |
| 500 | Server error |
DELETE /activities/{id}/exercises/{eid}Status codes
| Code Meaning | |
| 204 | Deleted successfully (no content) |
| 404 | Activity or exercise not found |
| 500 | Server error |
GET /goalsQuery parameters
| Param Type Description | ||
from | timestamp | Start of date range |
to | timestamp | End of date range |
term | string | Search goals by name |
page | integer | Page number |
limit | integer | Page size |
Response 200
{
"goals": [
{
"id": 0,
"name": "string"
}
],
"page": 0,
"total": 0
}
Status codes
| Code Meaning | |
| 200 | Success |
| 500 | Server error |
POST /goalsCreate a new goal.
Body
{
"name": "string",
"description": "string",
"startAt": "ISO timestamp",
"endAt": "ISO timestamp",
"target": 0
}
Adjust fields to match whatever attributes a goal actually tracks (e.g. target value/metric, linked activity type).
Status codes
| Code Meaning | |
| 201 | Created successfully, returns the goal |
| 400 | Invalid body (bad timestamp, missing fields, etc.) |
| 500 | Server error |
GET /goals/{id}Fetch a single goal.
Response 200
{
"id": 0,
"name": "string",
"description": "string",
"startAt": "ISO timestamp",
"endAt": "ISO timestamp",
"target": 0,
"progress": 0
}
Status codes
| Code Meaning | |
| 200 | Returns the goal |
| 404 | Goal not found |
| 500 | Server error |
PATCH /goals/{id}All fields optional; only defined fields are updated.
Body
{
"name": "string",
"description": "string",
"startAt": "ISO timestamp",
"endAt": "ISO timestamp",
"target": 0
}
Status codes
| Code Meaning | |
| 200 | Returns the updated goal |
| 400 | Invalid body |
| 404 | Goal not found |
| 500 | Server error |
DELETE /goals/{id}Status codes
| Code Meaning | |
| 204 | Deleted successfully (no content) |
| 404 | Goal not found |
| 500 | Server error |
Sync and event flow
Clients and wearables reach the system through a load balancer, which forwards everything to a gateway. The gateway rate-limits and routes requests. Reads (a user's stats, a goal's current progress) go straight to the primary database. Writes to activities and exercises get published as events to a log (Kafka) instead of being applied inline, and everything downstream reacts to that log rather than to a direct call.
Three consumers read the same stream independently:
The achievement service is a separate consumer that listens for goal-completed events and awards achievements. It doesn't sit inside the goal processor: goal logic shouldn't need to know what an achievement is, and adding a new achievement rule shouldn't touch the goal path at all.
Anything worth surfacing to a client, goal progress, a completed goal, a new achievement- becomes an event the notification service picks up. That service writes to Redis and pushes to the user's device. Redis does two jobs here: it's the cache backing activity reads, and it's the store behind the SSE connections clients hold open for live updates.
Wearables don't write to the event log directly. A wearable calls a webhook through the load balancer, and that call triggers a sync for the user rather than being trusted as data on its own. The event log's shape stays controlled by the gateway, not by whatever a given vendor decides to send.
Clients also pull activities on a schedule through the load balancer, independent of SSE and webhooks. SSE tells a client something changed, but connections drop, and webhook calls get missed, so periodic pull is the fallback that guarantees convergence. Two devices can edit the same activity while offline, so conflict resolution uses operation-based CRDTs rather than last-write-wins: each device's edits are a stream of operations, and merging those streams reaches the same state everywhere without a central lock.
A document-based database fits best here, since an activity's shape changes with its type (running, swimming, etc. each carry different fields), and forcing that into a fixed relational schema means either a wide sparse table or constant migrations as new activity types get added.
Four collections cover the data:
activitiesgoalsusersachievementsDocuments older than a year move into a separate archive collection per data type, so the hot collections stay small and queries against recent data don't have to scan past what users actually look at. Older data is still reachable, just through a different collection with its own (looser) query and indexing expectations.
Each collection is indexed on userId, timestamp, and, where it applies, activity type, since those are the fields every query filters on.
Sharding is geo-based, keyed on user location, so a user's data lives close to where they actually are. If a user's real location drifts from where their shard lives (they moved, or travel long-term), they get migrated to the correct datacenter after a sustained period, so the latency penalty is temporary rather than permanent.
Replication is single-leader per shard, with at least three cross-datacenter replicas for availability. Single-leader avoids the write-conflict problem multi-leader setups introduce, at the cost of higher write latency for users far from the leader, the migration path above is what keeps that cost temporary. Replication itself is async, so a follower isn't guaranteed to have the latest write the moment it lands.
Conflict resolution across the system runs on one mechanism: operation-based CRDTs, ordered by Lamport timestamps. The normal path is incremental, a device or replica sends the operations produced since its last sync. As a special case, if drift is too large to reconcile incrementally, it falls back to sending the entire stored operation list, or triggers a full sync, to rebuild state from scratch.
Staleness detection uses a version number rather than a timestamp: a document carries a version, and a device or replica compares its held version against the source's before trusting what it has.
The four collections relate to each other by embedding, not by reference. That trades consistency for read simplicity: a goal doesn't need a join to know about its activities, but the embedded copies inside goals and achievements can drift from the source of truth over time. For achievements specifically, this means a future change to how goals are structured has to be replayed into every achievement that embedded the old shape. That's acceptable, achievements are historical records and rarely change once created, and the replay doesn't need to happen eagerly: it's a lazy migration, applied when an old-shaped achievement is next read, rather than a batch job that has to touch every existing document up front.
Clients, wearables, and the third-party dashboard all enter through the same load balancer, which distributes traffic across gateway instances. The gateway is the only thing behind it that anything talks to, it rate-limits, authenticates, and routes. It never touches a datastore itself; every read and write it handles gets proxied to whichever service owns that data. This keeps the storage layer free to change (swap a database, add a cache, re-shard) without the gateway's routing logic knowing or caring.
Writes to activities and exercises don't get applied inline. The gateway publishes them as events to the log and returns; a client sees eventual application, not synchronous confirmation, since the actual write happens downstream.
Kafka is the backbone connecting every write to every service that needs to react to it. This decouples "an activity was logged" from "the achievement service found out about it", each consumer reads the stream at its own pace, and adding a new consumer later (a fraud-detection service, say) never requires touching the producer side.
Owns both the write and read side of activity data, deliberately kept as one service rather than split, so there's a single place that understands what an activity's current, correct state looks like.
GET /activities and GET /activities/{id} here. This service checks Redis first; on a miss it falls back to the primary database. It owns the decision of what counts as a hit or a miss, the gateway doesn't know Redis exists.Consumes activity events off Kafka and updates progress against each goal a user has active. When a goal's target is met, it emits a goal-completed event back onto Kafka rather than calling the achievement service directly, this keeps goal logic from needing to know achievements exist at all, and lets other future consumers (a leaderboard service, say) react to the same event without the goal processor being modified.
A separate consumer that listens only for goal-completed events and awards the corresponding achievement. Kept out of the goal processor on purpose: goal logic and achievement logic change for different reasons, and a new achievement rule shouldn't require redeploying the thing that tracks progress.
Owns primary-database reads for users, goals, and achievements. The gateway proxies these reads here instead of hitting the database directly, same reasoning as the activity service: whatever storage decisions get made for this data (add a read replica, add a cache later) stay behind this service's boundary.
Consumes every event on the log and does two things with it: updates precomputed aggregates (rollups per user, per goal, per time bucket) in the analytics database, and writes those aggregates through to a dedicated analytics cache. Because the aggregation happens as events arrive rather than at query time, a read is a lookup against something already summarized, not a scan over raw events.
Serves two kinds of reads through the same path: client-facing stats and goal-progress visualizations, and dashboard-facing usage reports. Both go through the gateway, the dashboard doesn't get a side door into the cache; it's proxied like anything else. There's one set of aggregates behind both, the dashboard just asks for a wider slice (usage across users) than a single client's stats call does.
Listens for goal-progress, goal-completed, and achievement events. On receiving one, it does two things: writes to Redis (which doubles as the store backing the SSE connections clients hold open) and pushes a notification to the user's device. Redis carrying both the activity cache and the SSE store is a deliberate reuse, one piece of infrastructure, two related jobs, rather than standing up a second in-memory store for what's conceptually similar data.
Wearables don't write to the event log directly, they call a webhook through the load balancer. That call triggers a sync for the user rather than being trusted as data on its own; the event log's shape stays controlled by the gateway path, not by whatever a given vendor decides to send in its payload.
Clients get three ways of finding out about state changes, each covering a gap the others leave:
Because a client can edit data offline and two devices can edit the same activity concurrently, conflict resolution runs on operation-based CRDTs, ordered by Lamport timestamps rather than wall-clock time, so ordering stays correct even when devices' clocks disagree. The normal sync path is incremental: a device sends the operations it's produced since its last sync, and they merge on the receiving end. If a device has drifted too far to reconcile incrementally, long offline period, detected staleness past some threshold, it falls back to sending its full stored operation list, or triggers a complete sync, rebuilding state from scratch instead of merging on top of a broken baseline.