QUESTPIE
Ship

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.

View markdown

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

PieceWhere it lives
SessionsPostgreSQL, via the Better Auth tables
Realtime changesThe questpie_realtime_log outbox table
Search indexPostgreSQL, on both search adapters
JobsWhatever your queue adapter talks to
Upload recordsThe 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.

src/questpie/server/questpie.config.ts
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.

Optionpg-bossBullMQ
teamSizeIgnored. pg-boss 12 has no such option.Worker concurrency, per job name
batchSizeJobs fetched per pollIgnored

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.

src/questpie/server/jobs/send-digest.ts
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

TopicPage
Counting connections, PgBouncer, LISTENThe connection budget
Every db.pool key and its defaultRuntime configuration
Cost of one live query at 100,000 clientsRealtime modeling
Admission caps and the outbox tableRealtime tuning
Long-running, one-batch, pushed workersWorkers
Schema changes while old replicas serveMigrations in production
Probes, traces and what to alert onMonitoring

Next

Monitoring is how you find out which of these ceilings you actually hit.

On this page