# Scaling (/docs/ship/scaling)

---
title: Scaling
description: 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.
kind: guide
package: questpie
---

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.

```ts title="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](/docs/infrastructure/storage) and
[Key-value store](/docs/infrastructure/kv).

## 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](/docs/infrastructure/realtime) has the adapter table.

<Callout
	type="warn"
	title="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](/docs/infrastructure/realtime/scaling) before you
	reach for one.
</Callout>

## 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.

```ts title="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](/docs/code/jobs/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](/docs/ship/scaling/connections) 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:

```ts
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](/docs/ship/scaling/connections)    |
| Every `db.pool` key and its default       | [Runtime configuration](/docs/ship/configuration/runtime)  |
| Cost of one live query at 100,000 clients | [Realtime modeling](/docs/infrastructure/realtime/scaling) |
| Admission caps and the outbox table       | [Realtime tuning](/docs/infrastructure/realtime/tuning)    |
| Long-running, one-batch, pushed workers   | [Workers](/docs/code/jobs/workers)                         |
| Schema changes while old replicas serve   | [Migrations in production](/docs/ship/migrations)          |
| Probes, traces and what to alert on       | [Monitoring](/docs/ship/monitoring)                        |

## Next

**[Monitoring](/docs/ship/monitoring)** is how you find out which of these
ceilings you actually hit.
