Scaling Plan

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.

Load profile today

PathCost per active learner per dayWhy it's cheap
App and contentNear zero at originImmutable files from the edge cache
Sync pushA few small batched requests≤200 mutations per request; analytics on a lower lane
Sync pullOnly when another device wroteHead check on push, resume, or every 5 minutes
JobsProportional to changed usersIncremental via learner_dirty

Stages

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"]

Stage 1: ten thousand daily learners

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.

  • Apply user-data mutations in one transaction per push and move the per-person rate limit off the database (Hardening items 4 and 6 on the Roadmap).
  • Add app machines horizontally; the API is stateless.
  • Keep jobs on one machine; they are incremental.
  • Tighten per-route limits at the edge as abuse patterns appear.

Stage 2: one hundred thousand

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.

  • Shard jobs by adding a partition key to the lease (for example, a hash of the user id), so several workers share the nudges and learner-state jobs.
  • Second API region (for example, Europe or the US) with reads served locally and writes forwarded to the primary region; sync is already tolerant of latency because it runs in the background.
  • Push fan-out batched per provider, with per-provider concurrency limits.

Stage 3: millions

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.

  • Regional "write homes" per user, chosen at sign-up, to keep writes local.
  • Streaming event ingestion into the lake, with Postgres used only for product state.
  • Feed policies served from the device remain the default; any server-side ranking would be cached per user.

What does not need to change

PartWhy it holds
Content deliveryStatic, content-addressed, globally cached; publishing cost is independent of users
Client architectureOffline-first and local planning already absorb network and server latency
ProtocolIdempotent, additive and versioned; servers can be added behind it freely
Privacy modelPseudonyms and per-user salts work the same at any size

Cost guardrails

  • Content egress stays on the edge, where it is cheapest.
  • The lake is Parquet on object storage, read with DuckDB, rather than a warehouse.
  • Event volume is bounded on the client (catalogue-only events, per-type caps, the 10-per-second guard).

${sch.nav()}