The architecture already pushes most work to the edges: content is static and cached globally, and the device does the planning, scheduling and rendering. The server mostly stores logs and serves change feeds, which scales well. This page lists what changes as usage grows.
| Path | Cost per active learner per day | Why it's cheap |
|---|---|---|
| App and content | Near zero at origin | Immutable files from the edge cache |
| Sync push | A few small batched requests | ≤200 mutations per request; analytics on a lower lane |
| Sync pull | Only when another device wrote | Head check on push, resume, or every 5 minutes |
| Jobs | Proportional to changed users | Incremental via learner_dirty |
flowchart LR
S0["Stage 0 · today<br/>testers<br/>1 region · 2 app machines<br/>single Postgres"] --> S1["Stage 1 · 10k DAU<br/>bigger Postgres · read replica<br/>pooled connections<br/>more app machines"]
S1 --> S2["Stage 2 · 100k DAU<br/>events straight to the lake<br/>job sharding by lease key<br/>second region for the API"]
S2 --> S3["Stage 3 · 1M+ DAU<br/>user-sharded sync store<br/>regional write homes<br/>streaming analytics"]
scale Postgres first. Move to a larger instance, put the API behind the transaction pooler (migrations already refuse it and use a direct connection), and add a read replica for analytics views and exports.
scale Get analytics out of the primary. Events are the largest table by far. The Worker can write events to the lake directly, keeping only recent days in Postgres for live views.
scale Shard by user. Everything a sync request touches is keyed by user id (
sync_mutations,user_changes, learning tables), so the sync store can be split across databases by user with no cross-user queries on the hot path.
| Part | Why it holds |
|---|---|
| Content delivery | Static, content-addressed, globally cached; publishing cost is independent of users |
| Client architecture | Offline-first and local planning already absorb network and server latency |
| Protocol | Idempotent, additive and versioned; servers can be added behind it freely |
| Privacy model | Pseudonyms and per-user salts work the same at any size |
${sch.nav()}