QUESTPIE

Scaling

A live query that re-runs per subscriber is correct at every size and affordable only at some sizes. This page is the modeling that decides which one you have.

View markdown

Every snapshot QUESTPIE pushes was recomputed from the database under that subscriber's own access. That is what makes it correct, and it is also the bill: one authoritative query per subscription group woken by a write it cannot be proven unrelated to. Writes landing while a group already recomputes collapse into one further query, so the count that matters is groups woken, not writes.

Three concerns that are not the same thing

Scope

An own scalar column the topic filters on by equality, which narrows which groups a write can wake. The narrowing happens in memory against the outbox projection, so an index on it pays for the surviving queries, not for routing. It partitions work. It does not authorize anything.

Access

Your .access() rules, re-evaluated from current database state on every recompute. A routing guard or a client-side filter never grants a row.

Client composition

Joining, ordering, limiting and deriving views from rows that are already authorized. That belongs in the client, over @questpie/tanstack-db. Reactive apps sizes subscriptions in React.

Put the scope on a column

src/questpie/server/collections/activity.ts
import { index } from "questpie/drizzle-pg-core";
import { collection } from "#questpie/factories";

export const activity = collection("activity")
	.fields(({ f }) => ({
		scopeId: f.text(128).required(),
		actorId: f.text(128).required(),
		message: f.textarea().required(),
	}))
	.indexes(({ table }) => [
		index("activity_scope_created_idx").on(table.scopeId, table.createdAt),
	]);

The outbox stores a shallow scalar projection of the changed row, so only a real own column can route. Membership expressed through a relation cannot, however well that relation is indexed.

When one scope grows too large, split it on a stable parent key or an immutable bucket key. Moving time windows, LIMIT/OFFSET pages and rankings are not partitions, because ordinary writes move rows across those boundaries.

What a subscription group is keyed by

Two subscribers share one group, and therefore one query per wake, only when the topic shape matches and all five of these agree.

PartValue
Identityshared:<key>, principal:<userId>, oauth:<tokenId>, or edge:<uuid>
ScopeWhatever subscriptionScope returned, or null
LocaleThe topic's content locale
StageThe resolved publish stage
Access modeuser or system

subscriptionScope

One resolver on the app config, run once per realtime connection and frozen for its lifetime. Reconnecting resolves it again.

src/questpie/server/questpie.config.ts
import { runtimeConfig } from "questpie/app";

import env from "./env";

export default runtimeConfig({
	db: { url: env.DATABASE_URL },
	realtime: {
		subscriptionScope: ({ request, session }) => {
			if (!session) return null;
			return request?.headers.get("x-workspace-id") ?? null;
		},
	},
});

null or undefined means explicitly unscoped. Any other value must be a non-empty string of at most 256 UTF-8 bytes, or QUESTPIE throws for you. The value only ever appears inside the server group key, never in an observer label or an error payload.

A scope partitions work, it does not authorize

A scope decides who shares a computation — it is not a second check on top of one. Within a group the snapshot is computed once, by the subscriber that created it, and the resulting bytes are sent to everyone in that group.

Row-level rules are safe by construction, because the access predicate is merged into the topic and the topic is part of the group key, so subscribers with different row scopes land in different groups. What is not re-run per subscriber is field-level access, columns and afterRead. Widen a key only where every principal that resolves to it is entitled to byte-identical rows.

accessCacheKey

The identity part of the key isolates each principal by default, and edge:<uuid> is minted fresh per connection, so anonymous subscribers never share with each other. A collection or global can override that with realtime.accessCacheKey, the only way many principals collapse into one group and one query, and the only source of the shared: form.

export const posts = collection("posts")
	.access({ read: true })
	.options({ realtime: { accessCacheKey: () => "public" } });

Return the same deterministic key only where you can prove the output is identical for every principal that gets it. The key is bounded to 256 UTF-8 bytes as well. A resolver that throws, or returns a non-string or an over-long key, isolates that subscriber to its own edge group instead of a shared one.

Conservative candidate routing

Each write is first tested against a payload-local guard, and only a proven miss skips work. Everything else continues to the normal database query, which stays authoritative. The guard compiles AND: [access rule, your where], so an equality predicate returned by an access rule narrows exactly as well as one you asked for.

Predicate in your whereGuard result
Own scalar column, field: value or { eq: value }match or miss
{ in: [...] }, up to 128 scalarsmatch or miss
AND, OR, NOT over the aboveThree-valued logic
A relation name, or RAWunknown
Any other operator, an empty or oversized inunknown
A field absent from the outbox projectionunknown

Updates evaluate the before and the after projection, so moving a row from one scope to another correctly wakes both. Bulk writes carry only ids and a count, so they are always unknown.

Positive equality anchors under AND also feed an indexed candidate router, so groups whose anchor value misses are never visited. Anchors under OR or NOT do not narrow the candidate set, they only evaluate. A topic that watches a second collection as a relation or access dependency is registered against that collection by name with no guard, so every write there wakes every such topic.

The 100,000-subscriber failure mode

One activity row belongs to a shared scope, but 100,000 users each subscribe through a personalized recipient relation. That relation is correctly classified unknown, so one insert makes all 100,000 groups candidates and every one of them re-runs its query. The scheduler runs at most ten of those recomputes at a time per instance, and that bound is not configurable, so the symptom is not a database storm. It is a backlog every subscriber waits behind.

RequirementModel
Bounded shared collection stateOne scoped live query on the collection
Per-user durable feedMaterialized inbox rows with a real recipientId
Shared audience notificationA typed channel keyed by audience
A ranking or page went staleA typed channel event, then a normal refetch
No push requirementA normal query

In a QUESTPIE checkout, bun --cwd packages/questpie run bench:realtime:routing runs both cases deterministically. It builds 100,000 subscriptions over 1,000 scopes and asserts one scoped event considers exactly 100 groups, then builds the adversarial one-scope relation case and asserts it considers all 100,000 and classifies as a snapshot for relation_where.

A collection that exists only as an access dependency should not be subscribable at all. .options({ realtime: false }) rejects its topics at admission but leaves change capture on, so topics that watch it as a dependency still refresh.

Diagnostics

app.realtime.getMetrics() returns counters and gauges. Pass realtime.observer to receive the same events live.

EventWhat it tells you
routing.candidatesGroups considered for one write, the fan-out number
routing.guardmatch, miss or unknown per candidate
routing.planAnchor required or missing, and what blocked it
routing.authoritative_dbOne per real query, the number you are paying
delivery.classifiedsnapshot or delta, and the reason
refresh.completedSubscribers served and frame bytes

Metric keys are assembled from a fixed label set, so no principal, scope, topic, record or query value ever reaches one. Observer failures are swallowed and cannot break delivery.

On this page