Scaling monlite on Postgres
Running N app instances (Node or Go) against one Postgres is the intended scale-out path, and every monlite component is safe under it by construction — all coordination lives in the database, not in any single process. This page is the capacity-planning cheat sheet.
What happens per component with N instances
| Component | Behavior at N instances |
|---|---|
| documents | Stateless per request — scales linearly until Postgres saturates. Read-for-write operations (update*, upsert, findOneAndUpdate, bulkWrite) row-lock their matches (FOR UPDATE), so concurrent writers serialize per row and CAS guards re-evaluate on the locked row — exactly-once claims hold across instances. |
| transactions | Each transactionAsync runs on its own pooled connection — concurrent transactions parallelize within and across instances, and retry automatically on serialization failure / deadlock (40001/40P01). |
| queue | Built for scale-out: FOR UPDATE SKIP LOCKED claims mean N workers each take distinct jobs. An add() NOTIFY-wakes workers in every instance (~ms pickup); polling remains only as a reconnect fallback. Use visibilityTimeout so a crashed instance's jobs auto-requeue. |
| cron | The atomic claim (UPDATE … WHERE next_run <= now) means exactly one instance fires each occurrence — more instances = redundancy. Add jitter to spread N instances' simultaneous ticks. |
| kv | Locks (setNX), counters (incr), and sorted-set updates are single atomic statements — correct under any concurrency. Pub/sub rides LISTEN/NOTIFY (no polling); messages reach every subscribed instance, in both runtimes. |
| watch() | One statement-level trigger NOTIFY per write statement (a 10k-row createMany = 1 notification, not 10k). Every watching instance re-queries on a relevant change — write-heavy watched collections × many watching instances multiply reads, so at high scale dedicate a small realtime tier rather than having every instance watch everything. |
| fts / vector | Generated columns — indexing cost is paid inside Postgres at write time, identical regardless of instance count. Standard GIN / HNSW scaling. |
Connection math — the first real wall
Each instance holds:
- a query pool (
pool.maxin Node,PoolMaxin Go — set these deliberately), and - ONE shared LISTEN connection total (all watch/queue-wake/pub-sub channels are multiplexed onto it).
So budget ≈ instances × (pool_max + 1). The monlite/postgres
image ships max_connections=200 (override with docker run … -c max_connections=N).
Example: 20 instances × (8 + 1) = 180 — fits.
PgBouncer: session mode only for anything monlite touches — transaction pooling silently breaks
LISTEN/NOTIFY. If you front the query pools with PgBouncer, keep the driver's own connection
(which carries LISTEN) pointed directly at Postgres.
Read replicas — mostly no
The queue, kv, cron, watch(), and every CAS/transaction must run against the primary.
Replicas only help plain reads that tolerate staleness — if you need that, open a second
createDb/Connect against the replica at the app level; monlite deliberately does not route.
When you outgrow one Postgres
In order: bigger box → partition the hot tables (_jobs first) → split databases per service.
That ceiling is far away — a tuned single node handles tens of thousands of mixed ops/sec — and
beyond it you've outgrown this architecture, not just this library.