Scaling
What a second app replica gets for free, the two defaults that stay behind on one machine, and the arithmetic that decides your real ceiling.
You have one app process and one worker. You want more of each. Most of that costs you nothing. This page is the part that does.
The app process keeps no request state. Sessions are rows in PostgreSQL, read through Better Auth's Drizzle adapter. Any replica can serve any request. Round-robin is fine, and you need no sticky sessions.
What a second replica already shares
| Piece | Where it lives |
|---|---|
| Sessions | PostgreSQL, via the Better Auth tables |
| Realtime changes | The questpie_realtime_log outbox table |
| Search index | PostgreSQL, on both search adapters |
| Jobs | Whatever your queue adapter talks to |
| Upload records | The upload collection's table, but not the bytes |
Two things are missing from that list. They are the two you configure first.
KV and storage stay on one machine
Leave kv out of your config and you get a Map in the current process. Leave
storage out and file bytes go to ./uploads on the current machine.
The two failures are not equal. An unshared cache is a slower cache. An unshared upload folder is worse. A file written by replica A is a 404 on replica B. The whole folder goes away on the next deploy anyway. Fix storage first.
import { s3 } from "files-sdk/s3";
import { redisKVAdapter } from "questpie/adapters/redis-kv";
import { runtimeConfig } from "questpie/app";
import { createClient } from "redis";
import env from "./env";
async function getRedis() {
const client = createClient({ url: env.REDIS_URL });
await client.connect();
return client;
}
export default runtimeConfig({
app: { url: env.APP_URL },
db: { url: env.DATABASE_URL },
storage: { adapter: s3({ bucket: env.S3_BUCKET }), basePath: "/api" },
kv: { adapter: redisKVAdapter({ client: getRedis, keyPrefix: "myapp:" }) },
});Nothing else moves. app.storage and ctx.kv keep the same methods either way.
See Storage and
Key-value store.
Realtime already crosses replicas
Every write appends one row to the outbox, inside the same transaction. An instance holding subscribers drains that table on a timer. It then pushes to the clients it holds. So a client on replica A sees a write from replica B with no configuration at all.
A change broker makes that fast, not correct. QUESTPIE wires
PgNotifyChangeBroker for you on a db: { url } app. A notice cuts the delay
from the 15000 ms reconciliation poll to milliseconds. Drop every notice and
delivery still happens, one poll later. A broker that goes unavailable tightens
the poll to at most 2000 ms by itself.
Swap that broker for Redis Streams when LISTEN/NOTIFY becomes the wrong shape
for your instance count. It is one config key and it moves no live() call.
Realtime has the adapter table.
Transport size is a different problem from query cost
Every snapshot is recomputed per subscription group, under that subscriber's own access rules. A bigger transport does not make that cheaper. Read Scalable realtime modeling before you reach for one.
Workers scale by process, not by option
app.queue.listen() takes teamSize and batchSize. Which one does anything
depends on the adapter. QUESTPIE ships no default queue adapter, so that choice
is already yours.
| Option | pg-boss | BullMQ |
|---|---|---|
teamSize | Ignored. pg-boss 12 has no such option. | Worker concurrency, per job name |
batchSize | Jobs fetched per poll | Ignored |
QUESTPIE runs a fetched pg-boss batch one job at a time. So on pg-boss, more throughput means more worker processes. There is no knob for it.
singletonKey needs a queue policy
pg-boss fixes a queue's policy when the queue is first created. The default
policy is standard. On that policy pg-boss stores your singletonKey and
never dedupes it, so two workers can run the same key at once. Declare
queuePolicy: "stately" in the job's options for the usual one-at-a-time
behavior.
import { job } from "questpie/services";
import { z } from "zod";
export default job({
name: "send-digest",
schema: z.object({ workspaceId: z.string() }),
handler: async ({ payload }) => {
/* ... */
},
options: { queuePolicy: "stately" },
});The adapter warns once per queue when a key arrives without a policy.
idempotencyKey is a different thing. It deduplicates dispatch and works on
every adapter. Passing both keys on one publish throws.
Workers covers the three consumer models.
The two ceilings
Connections. Count them per process, then multiply by replicas, then compare
with max_connections. The Drizzle pool is db.pool.max, 10 by default. The
pg-notify broker opens one more for LISTEN, and a second for NOTIFY after
the first write. pg-boss opens its own on top. Workers pay the same bill,
because they build the same app object.
The connection budget does the arithmetic and
covers PgBouncer.
The outbox. Capture runs on every collection and global write and appends one
row. It takes no shared lock, so writers do not queue behind each other and more
nodes mean more write capacity. Readers get their order from (txid, seq) under
PostgreSQL's own visibility watermark, which means a change is delivered once
every transaction that was already open when it committed has ended. So keep
write transactions short: a long one delays delivery of everything committing
alongside it. Retention starts only after the row first falls below that
watermark, so the delay cannot turn into cleanup loss. Prefer one bulk call over
a loop, because a batch captures a single outbox row rather than one per record.
Shutting down
listen() installs SIGINT and SIGTERM handlers for you. The worker stops
taking work and finishes what it holds. It force-exits after
shutdownTimeoutMs, which defaults to 10000. Pass gracefulShutdown: false to
own that yourself.
The app process is yours, because QUESTPIE ships no server.
createFetchHandler hands you a handler and your template mounts it. A correct
shutdown stops accepting, drains what is in flight, then releases resources.
Only the server can do the first two, so QUESTPIE cannot trap the signal for
you.
Codegen writes destroyApp() beside app. The TanStack Start starter calls it
from a Nitro plugin on both signals. The Hono, Elysia and Next starters do not,
so add it yourself:
import { destroyApp } from "#questpie";
process.once("SIGTERM", () => void destroyApp());destroyApp() disposes services in reverse dependency order. The queue stops
first, then realtime and search. Observability flushes after them, so buffered
spans survive. The database connection closes last. Give the platform a
termination grace period longer than your slowest job.
Where each topic lives
| Topic | Page |
|---|---|
| Counting connections, PgBouncer, LISTEN | The connection budget |
Every db.pool key and its default | Runtime configuration |
| Cost of one live query at 100,000 clients | Realtime modeling |
| Admission caps and the outbox table | Realtime tuning |
| Long-running, one-batch, pushed workers | Workers |
| Schema changes while old replicas serve | Migrations in production |
| Probes, traces and what to alert on | Monitoring |
Next
Monitoring is how you find out which of these ceilings you actually hit.