Long-term Scaling & Growth¶
This page is the growth runbook for the backend. It exists so a future engineer can grow the system to very large scale as a series of pre-designed, just-in-time, zero-downtime steps, each plugging into a seam that already exists, rather than a rewrite. It records the heavy infrastructure we deliberately did not build yet, the trigger that says "now build it", and the exact playbook for each.
Core principle¶
Scale by adding infrastructure behind clean seams, just-in-time, not by pre-building. The stateless tier autoscales on its own. The data tier scales by deliberate, pre-planned steps, each of which is config or deployment only because the seam is already in the code.
Two rules follow from this:
- Every heavy item has a seam waiting for it. Read replicas, a dedicated WebSocket tier, connection pooling, and partitioning are already reachable through config flags and process modes. Building them early buys nothing but carry cost.
- Build at the trigger, not before. Each item below names a metric threshold. Until it fires, the item stays unbuilt. This keeps feature velocity high and complexity low.
Current posture — what scales automatically today¶
| Capability | Mechanism | Where |
|---|---|---|
| Stateless pod autoscale | HPA on CPU + memory, min 2 / max 6 | devops k8s/apps/tomoda/overlays/prod/hpa-api.yaml, hpa-async.yaml |
| Process-mode split | one image, SERVER_MODE selects roles |
backend/cmd/server/main.go (resolveMode) |
| Media offload | S3 + CloudFront signed URLs, served straight to clients | backend/config/config.go (S3Config), backend/internal/platform/assets |
| Cross-pod real-time | Redis pub/sub fan-out between hub pods | backend/internal/platform/ws/session_hub.go, client_hub.go |
| Cache / hot path | Redis via the cache bundle | backend/internal/platform/cache/cache.go |
| Bounded-cost reads | keyset (cursor) pagination everywhere, one codec | backend/internal/platform/cursor/cursor.go |
| No ORM overhead | sqlc + pgx, hand-tuned SQL | backend/internal/services/*/*db/ |
| Safe boot migrations | goose under a Postgres advisory lock | backend/internal/database/database.go (Migrate, migrationLockKey) |
| SQL isolation | every query lives in the store layer | backend/internal/services/*/store.go |
| Background work | Asynq worker + leader-safe scheduler | backend/internal/async/ |
The store-layer isolation is the load-bearing convention. Because no handler or service issues raw SQL (see the backend layering rules), replicas, caches, and shard routers all plug in at one layer instead of being threaded through business logic.
Seams already installed¶
These are the enablers. Each makes its future scaling step config or deployment only.
| Seam | What it is | What it unlocks |
|---|---|---|
| Read/write pool split | database.ReadPool embeds a second pgx pool; DB_READ_HOST selects it, else it aliases the primary |
Read replicas become a config flip. Read-heavy stores already take database.ReadPool (e.g. NewDiscoveryStore in backend/internal/services/discovery/store.go:37). |
| WS process-mode split | ws-hub / api-hub / multi-hub modes in resolveMode |
A dedicated WebSocket tier is a new Deployment plus a route, no code change. |
DB_MAX_CONNS |
per-pool connection cap (PoolMaxConns, default 20) |
Lets a pooler be sized as pod autoscaling multiplies pools. |
chat_messages partitioning |
table is PARTITION BY RANGE (created_at) with a default partition |
Query pruning, per-partition vacuum, and later cold-storage archival (not deletion) without a schema rewrite. |
| Redis pub/sub fan-out | RedisSessionChannelFmt / RedisClientChannelFmt, PSUBSCRIBE session:* / client:* |
Real-time already survives multiple hub pods; the next step is narrowing subscriptions, not adding fan-out. |
The scaling roadmap¶
| Step | Trigger (do-it-now signal) | Zero-downtime? | Effort | Seam it plugs into |
|---|---|---|---|---|
| Read replicas | primary read CPU sustained high; replica-lag budget healthy | Yes | Hours | database.ReadPool / DB_READ_HOST |
| Connection pooler (transaction mode) | connection count nears primary max_connections |
Yes (rolling) | Hours | DB_MAX_CONNS |
| Dedicated WS tier | WS connection count high; API-pod memory rising from socket buffers | Yes (rolling) | ~Days | ws-hub / api-hub modes |
| Table partitioning (notifications) | table size / autovacuum pressure | Yes | Days | PARTITION BY RANGE pattern (proven on chat_messages) |
| Fan-out feed / timeline | feed-read DB load dominates | Yes (interface swap) | Weeks | discovery/feed read path in the store/service |
| WS directed routing, then presence sharding | pod count x message rate; then Redis pub/sub saturation | Yes (rolling) | 1-2 months | PSUBSCRIBE fan-out in the hubs |
| Sharding (Citus or app-level) | write throughput a primary + replicas can't hold | Careful | Weeks | store layer contains the change |
| Multi-region | global latency / data residency / DR needs | Careful | 3-6 months | externalized state (Postgres, Redis, S3) |
| Streaming + search cluster | Asynq / PostGIS-SQL hit their ceilings | Yes (additive) | Weeks | async event path, discovery query path |
Read replicas¶
- Trigger. Primary read CPU sits high under steady load while write volume is fine, and the replica-lag budget (staleness the read paths tolerate) is acceptable.
- Playbook. Provision a CNPG replica (raise
instancesin the devopspostgres-prodCluster,k8s/envs/prod/postgres/manifests/cluster.yaml, currentlyinstances: 1). PointDB_READ_HOSTat the replica service. Stores that tookdatabase.ReadPooloffload their reads on the next rollout with no code change. - Zero-downtime? Yes. The read pool aliases the primary until the env var is set (
ReadDSN/HasReadReplicainbackend/config/config.go). - Seam.
database.ReadPool,DB_READ_HOST.
Connection pooler (transaction pooling)¶
- Trigger. Total backend connections approach the primary's
max_connections(currently100in the prod Cluster) as pod autoscaling multiplies pgx pools. - Playbook. Put a transaction-mode pooler (PgBouncer) in front of CNPG. Size
DB_MAX_CONNSper pod sopods x DB_MAX_CONNSfits the pooler's server-side pool, not the raw Postgres cap. - Pooler-safe by config. Transaction-mode pooling breaks the default per-connection prepared-statement cache. This is already handled: set
DB_POOLER=trueandpgx.goswitches to the pooler-safeCacheDescribeexec mode (backend/internal/database/pgx.go). Off by default so direct connections keep the faster cache, so the cutover is config-only, not a coupled app release. - Zero-downtime? Yes, rolling. The pooler is a network hop in front of the DSN.
- Seam.
DB_MAX_CONNS(PoolMaxConns).
Dedicated WebSocket tier¶
- Trigger. WS connection count climbs, or API-pod memory rises from per-socket send buffers competing with request handling.
- Playbook. Add a
ws-hubDeployment (SERVER_MODE=ws-hub), route/wsto it at ingress, and flip the API pods toapi-hubso they stop running the hubs. Both modes already exist inresolveModeand are reserved for exactly this split. The full-stackmulti-hubmode stays the default until then. - Zero-downtime? Yes. Roll out the
ws-hubDeployment first, move the route, then roll the API pods toapi-hub. Cross-pod fan-out over Redis keeps delivery correct across the mixed fleet during the roll. - Effort. ~Days (mostly ingress routing and HPA tuning).
- Seam.
ws-hub/api-hubprocess modes.
Table partitioning¶
chat_messages is already PARTITION BY RANGE (created_at) with a chat_messages_default catch-all partition (backend/db/migrations/00001_init.sql). The proven pattern is: add time-window child partitions on a schedule (done at boot by ensureChatMessagePartitions in internal/migrate), keep autovacuum bounded per partition, and at large scale detach cold partitions to cheaper storage.
Retention policy (why there is no age-based prune). Chat history is core social data users expect to keep, so messages are retained, not aged out. The policy already lives in code: friend and DM messages persist until the user deletes their account (internal/services/chat/message_purger.go), event-chat messages are purged per that event's retention setting, and account deletion purges a user's chats and messages for compliance (internal/services/user/user_service.go). Partitioning here buys query pruning and vacuum relief, and later cheap archival, not deletion. So the scale step is archival (detach an old partition, move it to cold storage or a cheaper tablespace), never a blanket drop.
- Trigger. A high-churn table's size or autovacuum pressure grows faster than the primary can comfortably vacuum.
- Playbook (notifications, the next candidate). Notifications is blocked by its dedup design:
idx_notifications_unique_openandidx_notifications_unique_unseenare partial-unique indexes on(user_id, group_key). A partitioned table requires the partition key (created_at) in every unique index, but addingcreated_atto these indexes breaks dedup, because the same open notification would become "unique" across time windows and stop merging. The fix, when the trigger fires, is either app-level dedup (an upsert guarded in the store instead of a partial-unique index) or a partition strategy that does not fight the dedup key. Until then, notifications stays a plain table. - Zero-downtime? Yes, when done as add-child-partition + background backfill.
- Seam. The
PARTITION BY RANGEpattern already in the schema.
Fan-out feed / timeline service¶
- Trigger. Feed and discovery reads dominate DB load, and the read path can no longer be served cheaply from the primary plus replicas.
- Playbook. Introduce precomputed timelines behind the existing discovery/feed read path. Because that path is a store/service interface, this is an interface swap: the handler and service stay, the store implementation changes from a live query to a read of a materialized timeline. Use a hybrid model, fan-out-on-write for normal accounts (push new items into follower timelines) and pull at read time for high-fan-out accounts (avoid writing to millions of timelines).
- Zero-downtime? Yes. Ship the new store behind a flag, dual-read to compare, then cut over.
- Effort. Weeks.
- Seam. discovery/feed read path (
backend/internal/services/discovery/store.goand its service).
WS directed routing, then presence sharding¶
Today every hub pod runs PSUBSCRIBE session:* (and client:*), so every pod receives every message and drops the ones it does not hold. That is fine at low pod counts and simple to reason about. It amplifies with scale: total Redis pub/sub traffic grows with pods x message rate.
- Step 1 — directed routing. Pods subscribe only to the specific room channels they currently hold, instead of the wildcard pattern. This kills the fan-out amplification. It rewrites a tested delivery path (subscribe/unsubscribe on room join/leave, correct handling of the last member leaving), so it is deliberately just-in-time, not now.
- Trigger. pod count x message rate makes wildcard fan-out a measurable share of Redis or pod CPU.
- Step 2 — presence sharding. Shard the presence registry and route delivery directly to the pod holding a connection (a routing layer keyed by connection, not broadcast).
- Trigger. Redis pub/sub itself saturates even with directed subscriptions.
- Zero-downtime? Yes, rolling, but step 1 changes correctness-critical delivery code, so it ships with heavy testing (miniredis + real-stack).
- Effort. 1-2 months across both steps.
- Seam.
PSUBSCRIBEfan-out insession_hub.go/client_hub.go.
Sharding¶
- Trigger. Write throughput exceeds what a single primary plus read replicas can hold. This is the last resort for the write path, after replicas, pooling, partitioning, and feed fan-out.
- Playbook. Shard the hot tables by a documented key (chat by
chat_id, and similar per-table keys chosen so cross-shard queries stay rare). Use Citus (distributed Postgres, keeps the SQL surface) or app-level routing. Either way the change is contained in the store layer, because that is the only place SQL and connection selection live. - Zero-downtime? Only with care: online reshard, dual-write, backfill, cut over.
- Effort. Weeks, and the carry cost is permanent and high. Cross-shard joins, rebalancing, and shard-aware migrations become a standing tax.
- Seam. store layer.
Multi-region¶
- Trigger. Global latency targets, data-residency requirements, or disaster-recovery needs that a single region can't meet.
- Playbook. State is already externalized (Postgres, Redis, S3), so the app tier can run as regional stacks. Add per-region stacks, decide data locality and residency (which data is regional vs global), and add latency- or residency-aware routing at the edge.
- Zero-downtime? Introducable region by region, but data locality is a design decision that must be right up front.
- Effort. 3-6 months. Roughly 2-3x the infra bill and very high permanent carry (replication, routing, per-region ops).
- Seam. externalized state.
Streaming + search cluster¶
- Trigger. Asynq (Redis-backed queue) can't hold the event/fan-out volume, or PostGIS-plus-SQL discovery queries hit their latency ceiling.
- Playbook. Add a streaming backbone (Kafka or equivalent) for high-volume event fan-out alongside the async event path, and a dedicated search cluster (a search engine) behind the discovery query path. Both are additive: the async handlers and the discovery store are the seams they attach to.
- Zero-downtime? Yes, additive and behind the existing interfaces.
- Effort. Weeks per subsystem.
- Seam. async event path (
backend/internal/async/), discovery query path.
Cost reality¶
Order-of-magnitude only. Build is lost feature velocity; Run is infra spend; Carry is the standing complexity tax after it ships.
| Item | Build (eng-months) | Run ($/mo, o.o.m.) | Carry |
|---|---|---|---|
| Read replicas | days | $ | Low |
| Connection pooler | days | $ | Low |
| Dedicated WS tier | ~days | small | Low |
| Table partitioning (notifications) | days | negligible | Low |
| Fan-out feed | 2-3 | $0.5-2k | Medium |
| WS directed routing + presence sharding | 1-2 | small-moderate | Medium |
| Sharding | 2-4 | $1.5-5k+ | HIGH, permanent |
| Multi-region | 3-6 | 2-3x current bill | VERY HIGH |
| Streaming + search cluster | weeks each | $1-3k | Medium-High |
Takeaway. The cost of building early is mostly lost feature velocity plus permanent complexity, not the cloud bill. Every heavy item above waits for its trigger. Building sharding or multi-region before the metric demands it buys nothing and taxes every future change.
What to watch (SLOs / alerts)¶
These signals fire before the triggers above, giving lead time to build the next step. They live in the devops monitoring stack (Prometheus / pod monitors; the CNPG enablePodMonitor is already on), not in the app.
| Signal | Warns of | Feeds trigger |
|---|---|---|
| DB primary CPU | read or write saturation | read replicas, sharding |
| Replica lag | replica can't keep up | read-replica read-budget |
Connection count vs max_connections |
pool exhaustion | connection pooler |
| WS connections per pod | socket-buffer memory pressure | dedicated WS tier |
| Redis pub/sub throughput | fan-out amplification | WS directed routing, presence sharding |
| Table size / autovacuum time | vacuum pressure | table partitioning |
| p99 request latency | end-to-end degradation | feed fan-out, search cluster |
| Asynq queue depth / latency | worker backlog | streaming backbone |
The WS-connection-count metric is not yet exported; the API HPA notes it as a future custom metric via prometheus-adapter (see the comment in devops hpa-api.yaml). That exporter is a prerequisite for auto-triggering the dedicated WS tier.
See also¶
- Real-time — hub topology and the Redis fan-out this page scales.
- Technical Decisions — why sqlc/pgx, the process-mode split, and externalized state.
- Database — pools, migrations, the read/write seam.
- Async — the worker and scheduler that the streaming step extends.