Subscriptions

How reactive query subscriptions stay live over WebSocket.

A subscription is what the browser gets when it calls useSubscription(...) for a defineQuery RPC. The app code writes a query pair; the runtime keeps that query live over WebSocket and pushes new snapshots or deltas when matching data changes.

Every table is reactive by default, so a query over any table is live with nothing to configure. A table explicitly marked .nonReactive() emits no change events at all — a subscription over one returns its first snapshot and then stays silent forever, which is why voltro dev warns about that combination at boot.

Subscriptions are not a separate file convention anymore. The file convention is queries: *.query.ts for the descriptor and *.query.server.ts for the executor.

Live — add a todo (or open this page in a second tab) and the list updates with no refetch; the <DataTable> is a subscription under the hood:

<AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="app" query="todos.list" />   {/* live subscription */}

Lifecycle

const { data } = useSubscription('app', 'notes.list', { archived: false })
  1. The client opens or reuses the API WebSocket.
  2. It sends the query tag and input.
  3. The runtime runs notes.list.query.server.ts.
  4. The first value is delivered as a snapshot.
  5. Later mutations emit change events when their transaction commits.
  6. Matching query subscribers receive the updated value.

From React, data just changes. There is no refetch call.

Query Descriptor

// apps/api/queries/notes.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'

export const listNotes = defineQuery({
  name:   'notes.list',
  source: 'notes',
  guards: [{ scope: 'notes:read' }],   // re-checked on every delivery, not just at open
  input:  Schema.Struct({ archived: Schema.Boolean }),
  output: Schema.Array(Schema.Struct({
    id:    Schema.String,
    title: Schema.String,
  })),
})

source declares which table re-runs computed queries and lets mutations with matching target metadata patch the client cache optimistically.

Reactivity channels

source: usually names a table. It does not have to, and when the state a query reads is not in the database, naming one is the wrong answer.

import { defineQuery, reactivityChannel } from '@voltro/protocol'
import { Schema } from 'effect'

// Declared once, in a browser-safe module both sides import.
export const jobQueue = reactivityChannel('job-queue')

export const queueDepth = defineQuery({
  name:   'jobs.depth',
  source: jobQueue,                     // ← a channel, not a table
  input:  Schema.Struct({}),
  output: Schema.Struct({ pending: Schema.Number }),
})

Then push it from wherever the state actually changes:

import { publishReactivity } from '@voltro/protocol'

publishReactivity(ctx.store, jobQueue)   // every subscriber re-runs its executor

Everything else is unchanged: the executor returns a plain value, the framework re-runs it and pushes the result over the same subscription transport a table-backed reactive query uses. There is no second client concept and no second push mechanism — useSubscription does not know the difference.

Why not just declare a table

Because the alternatives are worse, and the framework shipped one of them for a release. Presence held its roster in memory and still declared a _voltro_presence table it never wrote a row to, purely to own a name the reactivity layer would route on — an empty table in every user's database, created by every migration and diffed on every boot.

The other tempting option is to point source: at a name that resolves to nothing. That is worse than the empty table: the stale-source boot warning is the only signal for a subscription that has gone permanently quiet, and an exemption for a name you invented disables it for the one case it was built for.

A table source: is typechecked

voltro dev writes voltro-tables.generated.d.ts beside your generated rpc group, listing every table the app has — your entities, your plugins' extendSchema.tables, and the framework's own. source: is narrowed to those names, so a typo or a table you renamed away is a compile error:

source: 'task_subtasks',   // ✗ Type '"task_subtasks"' is not assignable to type 'TableName'
source: 'task_sub_tasks',  // ✓

That matters because the failure it replaces is silent. A source: is matched by NAME against change events, so one that matches nothing does not break the query — it makes it never update. The write still lands, a reload still shows it, and the panel keeps showing the old value.

The file is generated, so commit it like the rpc group and let voltro dev rewrite it. Before the first run — and in a project that never generates it — source: is plain string again, which is exactly the previous behaviour; there is no configuration and nothing to opt into.

The error carries a suggestion when the name is close to a real one — Did you mean '"error_logs"'? — so a rename usually resolves without leaving the editor.

A COMPUTED source: can keep its names

A generic reader — the table arrives in input, the caller picks it out of a registry — is computed, and yet every name it can produce is known. The obvious derivation is not assignable:

source: Object.keys(JUNCTION_REGISTRY),   // string[] — ✗

The tempting exit is a cast, or annotating ReactivitySourceValue (the wide string | ReactivityChannel shape, exported from @voltro/protocol). Both compile at once and take that entire set of tables out of the check permanently. Keep the literal's keys instead:

const REGISTRY = { … } as const satisfies Readonly<Record<string, JunctionMeta>>

// consumers that index with a plain string still get the wide type
export const JUNCTION_REGISTRY: Readonly<Record<string, JunctionMeta>> = REGISTRY

// … and the source list keeps its literal names
export const JUNCTION_TABLE_NAMES = Object.keys(REGISTRY) as ReadonlyArray<keyof typeof REGISTRY>

The part that is easy to get wrong: an annotation widens the keys back, even when the literal carries as const. A registry written as export const R: Readonly<Record<string, Meta>> = { … } as const has keyof typeof R === string, and nothing about it looks wrong — the annotation is checked against the literal and then replaces its type. satisfies checks without replacing. That is the whole reason for the two-line split above.

Reach for ReactivitySourceValue when there genuinely is no key set to keep — a name read from a config file, or assembled at runtime. Not when recovering one takes two lines.

Two things it deliberately does not narrow. A plugin's route source: stays string: a plugin ships against many apps and cannot know any of their tables. And nothing that READS a descriptor's source at runtime narrows either — a reader that refused an unknown name would reject the stale name it exists to report.

It does not check the other direction — except for relations. A source: that omits a table the query genuinely reads is silent: the name is right, the table exists, and nothing has an opinion. That is the failure that costs a user report — they type, the row lands, and the panel does not move.

voltro doctor closes the part of it that can be closed without guessing:

✗  1 query loads a relation it does not declare:
   tasks.getById: eager-loads `subTasks` from 'tasks' but does not declare
   'task_sub_tasks' in `source:` — the view will not update when 'task_sub_tasks' changes.

An eager-loaded relation is composition by definition — its rows are IN the result — and its table comes from the relation registry, so the rule has no heuristic and no exception list. A many-to-many wants the junction table too, and says so separately: adding or removing a link writes only the junction row, so declaring the target alone leaves the list stale on exactly the operation a user performs to change it.

Nested relations resolve in the same pass: a relation loaded under .with({ subTasks: { with: { watchers: true } } }) is looked up on task_sub_tasks, not on the query's base, so both levels are reported at once rather than one per run.

The one thing it still cannot see is a base table it cannot find — neither declared in source: nor written as database.<name> / .select('<name>'). There is then no table to resolve the relations against, and the query would otherwise report clean while every relation on it is unchecked. So doctor says so:

⚠  1 query could not be fully checked — the base table is neither declared nor readable from the executor:
   reports.byKind: `blocked` resolved against no table
   declare the base table in `source:` and re-run — relations under it are unchecked until then.

What doctor deliberately does NOT do is derive every table an executor reads. That needs a judgement — does this read compose the result or merely restrict it? — and only composing reads belong in source:; a restricting one re-running on every unrelated write puts a hundred lists back on the wire. A scan has to infer that from syntax, and a rule that guesses on a correct codebase teaches you to ignore it.

So: when a live view does not update, check the writing table is in the reading query's source: before anything else.

Pass the channel, not its key

A channel's routing key is channel:<name>, and you can read it off jobQueue.key. Do not write that string into source:. Passing the object creates an import edge from the query to the declaration, which removes the entire class of stale-source bugs for channels: a channel that is not imported does not exist to be named. The boot audit reports a channel: key nothing declared, for the two ways round it.

Names are lowercase kebab segments separated by dots — presence, job-queue, billing.usage. A : is refused (it is the namespace separator) and so is an uppercase letter (a key that differs only by case reads as one channel and routes as two). Declaring the same name twice returns the same channel.

A publish is LOCAL news, and says so

The change event a publish emits carries origin: 'inline' — the same stamp a write made in this process carries. An event that arrives from another replica carries origin: 'injected'. So a listener can tell the two apart:

store.onChange((event) => {
  if (event.table !== jobQueue.key) return
  if (event.origin === 'injected') {
    // a PEER published — mirror it into local state, then publish onward if
    // this node has anything of its own to add
  }
})

That distinction is what makes a fan-out pattern writable. Without it, a replica's own publish and a peer's are indistinguishable, and the only way to avoid an echo is a boolean per channel.

Two consequences worth knowing:

  • Re-publishing from inside a change listener works, synchronously. A channel published while handling a peer's event reaches the other replicas like any other publish. (This is worth stating because it did not use to: plugin-broadcast suppressed every emission made while it was injecting, so such a publish woke the local node and silently never left it.)
  • origin never crosses the wire. It describes how an event reached this process, so the receiving replica always stamps its own. A publish that says 'inline' here arrives as 'injected' there, which is the truth on both ends.

What a channel is not

  • Not an event. defineEvent carries a PAYLOAD to subscribers with replay, ordering and gap detection. A channel carries nothing — it says "re-read", and the subscriber's own executor decides what that means. Reach for an event when the message matters; a channel when the state does.
  • Not cross-replica. publishReactivity wakes subscribers on the node that called it. Fanning a change out to other replicas is @voltro/plugin-broadcast's job, exactly as it is for table changes on a dialect without CDC.
  • Not free per subscriber. A publish wakes every subscriber of that channel and re-runs each one's executor; the channel is one routing key, so subscribers looking at different slices of the state are woken too. Publish on a real change, not on a timer — see Fan-out.

Query Executor

// apps/api/queries/notes.list.query.server.ts
import { eq } from '@voltro/database'
import { database } from '../database/schema'

export default (input: { archived: boolean }) =>
  database.notes.where(eq('archived', input.archived)).orderBy('createdAt', 'desc')

Descriptor-returning executors get fine-grained row matching. Computed-return executors can return arrays, objects, scalars, or null; they should declare source so the runtime knows what table should re-run them.

skip

Use { skip } when the input is not ready yet:

const { data } = useSubscription(
  'app',
  'messages.list',
  { channelId },
  { skip: channelId === undefined },
)

While skipped, no WebSocket subscription opens and data stays undefined.

A failed cold start is failed, not loading

If the first snapshot never arrives because the subscription ERRORED, the state is failed: true, loading: false, data: undefined — nothing is in flight and nothing more is coming, so "it is loading" would be a false statement. error carries the cause.

const s = useSubscription<Team[]>('app', 'teams.list')

if (s.loading) return <Skeleton/>
if (s.failed)  return <RetryPanel error={s.error}/>
return <TeamsTable teams={s.data}/>

failed is a positive check on purpose. The state used to be loading: true with error set, and the escape hatch was reading that second field — which the natural shape of a wrapper ({ data, loading } passed through) drops. If you wrap this state, carry failed with it, or your callers inherit an infinite skeleton through your hook.

Two things this does NOT cover. A failure AFTER the first snapshot leaves the good data on screen and sets error — replacing working rows with an error because the socket hiccuped is worse than the hiccup. And the failed state is terminal for one TRANSPORT, not forever: a reconnect discards the error and re-subscribes, so the entry returns to loading on its own.

Skipped is idle, not loading

A skipped subscription reports idle: true, loading: false. The two are different questions — "waiting for the first snapshot" and "not asking at all" — and conflating them breaks the pattern this hook otherwise blesses:

// WRONG on a skipping call site — renders a skeleton for a query you switched off
if (loading) return <Skeleton/>

Passing a dynamic skip therefore changes the return type: you get a third state, and TypeScript will not let you ignore it.

const s = useSubscription<Team[]>('app', 'teams.list', {}, { skip: !open })

if (s.idle)    return null          // deliberately not asking
if (s.loading) return <Skeleton/>   // asking, no answer yet
return <TeamsTable teams={s.data}/> // `data` is Team[] — narrowed

Call sites that never skip are untouched — if (loading) still proves data is present there, and a literal { skip: false } counts as never skipping. The third state exists only where "not asking" is a real outcome.

With a fallback there is nothing to narrow either way: data is always present, and idle tells you whether what is on screen is the fallback because you chose not to ask.

A subscription that was live and is then skipped goes idle — it does not keep serving the snapshot it still holds. Otherwise skip: !open would show last time's data the moment a dialog reopens.

SSR preload — first paint with data

By default a useSubscription on an SSR page flashes its empty/loading state on mount, THEN opens the WebSocket and fetches the first snapshot — even though the server could have fetched that value during the render. usePreloadedSubscription closes that gap: it reads its first value from the SSR hydration payload, renders real data on the first paint, then upgrades to the live stream the instant its first snapshot lands.

Two things wire it up: the hook, and a preload export on the page.

import { usePreloadedSubscription } from '@voltro/client'

export const preload = ['notes.list']

export default function NotesPage() {
  const { data } = usePreloadedSubscription<Note[]>('app', 'notes.list')
  // `data` is present on the first paint — no loading flash
  return <NotesTable notes={data} />
}

export const preload lists the subscriptions the page's tree needs at first paint. During the SSR render (voltro dev and voltro start) the framework runs each one server-side — the SAME ctx.query(tag, input) a loader receives — and seeds the result into the hydration payload. The client's usePreloadedSubscription finds the seed by the SAME cache key useSubscription uses, so the server markup and the client hydration render read an identical value — there is no hydration mismatch. The live subscription still opens and takes over; the seed is only the first value, never the source of truth.

preload entries

An entry is either a bare tag or a tag plus an input derived from the route params:

export const preload = [
  'teams.list',                                                    // no input
  { tag: 'project.detail', input: (params) => ({ id: params.id }) },
]

The input you derive here MUST match the input you pass the hook — both address the same cache entry:

export default function ProjectPage({ params }: { params: { id: string } }) {
  const { data } = usePreloadedSubscription('app', 'project.detail', { id: params.id })
  return <ProjectView project={data} />
}

Falls back to useSubscription

When no seed exists for the key — a client-side SPA navigation the server never rendered, or the static prerender (which has no live api origin) — usePreloadedSubscription behaves EXACTLY like useSubscription: it loads until the stream answers. So it is always safe to reach for; the preload is a first-paint optimization, never a correctness dependency. A failed preload is likewise non-fatal — the live subscription still delivers the value on the client, the only loss is the first-paint seed.

preloadFailed — "no data" vs "could not get data"

A preload that FAILED server-side and one that was never declared arrive the same way: as the absence of a seed. That makes an empty first paint ambiguous, and the ambiguity is not academic — a session cookie that has outlived the IdP's token lifetime makes EVERY preload on the page fail at once, so the page renders its empty state while the server log holds the only explanation.

preloadFailed separates the two:

const projects = usePreloadedSubscription<Project[]>('app', 'projects.list')

if (projects.loading) return <Skeleton/>
if (projects.preloadFailed) return <Spinner label="Loading…"/>  // not empty — unasked
return <ProjectTable rows={projects.data}/>

It is a boolean and says nothing about WHY. The server's failure text is a refused call's error message; it belongs in the server log, which is the one place a browser cannot read. It also says nothing about the LIVE subscription, which usually recovers on its own — the browser reconnects with a credential the SSR request did not have. Read it as "the first paint has no server data, and that was not for lack of asking", which is exactly enough to pick a spinner over an empty state.

Seeding by hand

export const preload is sugar over an explicit seed. When a loader ALREADY has the value — you fetched it for the <title>, a breadcrumb, or the row name — seed it directly with seedPreloadedSubscription (server-side only) instead of fetching it a second time:

import { seedPreloadedSubscription } from '@voltro/client'

// inside a loader / layout loader, server-side
const notes = await ctx.query('notes.list', {})
seedPreloadedSubscription('app', 'notes.list', {}, notes)

Calling it outside a server render throws — on the client the live subscription already provides the value, so a client-side seed would be meaningless.

Streams Are Different

For non-database or transient element feeds, use streams, not subscriptions:

const ticker = useAgentStream('app', 'ticker.watch')
ticker.start({ symbol: 'BTC' })

Queries/subscriptions are for live state. Streams are for one-shot element flows such as tokens, progress events, and import logs.

Reconnect

A dropped WebSocket rebuilds the whole client stack — new socket, new RPC client, new subscription cache — and re-subscribes every active query. Inside the resume window (reactive.resume.windowMs, default 60 s) the server replays only the deltas the client missed — the re-subscribe presents the last materialised revision and the stream continues on the same revision line, so a short offline gap costs a handful of patches instead of every row. Outside the window, for computed queries, for a subscription whose source table a registered row filter may narrow, or whenever anything is in doubt, the query answers with a fresh snapshot — the delta-resume wire contract lives in the wire protocol.

What is on screen while that happens is your last-known-good data, not a skeleton. The replacement cache is seeded from the one it retires, so data keeps its previous value and loading stays false across the gap; the first snapshot on the new stream replaces the stale rows. There is nothing to opt into:

const { data, loading } = useSubscription('app', 'notes.list', {})
if (loading) return <Skeleton/>   // does NOT fire on a reconnect
return <NoteList notes={data}/>

Use useConnectionStatus if you want to tell the user the rows may be a few seconds old — the data itself never disappears from under them.

Three things are deliberately NOT carried across:

  • Optimistic patches. They are client-local and belong to mutations that died with the old connection, so nothing could ever retract them. They are reverted when their mutation settles.
  • A cold-start error. The new connection re-establishes the truth.
  • Entries nothing re-subscribes to. A screen that unmounted during the reconnect does not pin its rows; the seed evicts on the normal inactive TTL.

The local-first mirror partitions by subject — the carve-out

An app using @voltro/local-first's query mirror keeps rows on the DEVICE across reloads. The blank-on-auth rule extends there structurally: every mirrored key carries the subject AND tenant, so the next subject's binding simply never finds the predecessor's rows, and a logout or membership revocation calls purge() on the departing partition. Nothing about the in-memory blanking above changes.

An auth change still blanks — on purpose

When the rebuild happens because the connection's subject changed — a cookie login, a logout, a tenant switch, i.e. useReconnect()nothing is carried over and the screen does go back to its loading state.

That is not a gap in the feature, it is the point of the gate. The next subject may be entitled to strictly less than the previous one, so painting the previous subject's rows into their session, even for the moment before the first snapshot lands, would be a data exposure. The same rule applies to useRefreshSubscriptions, which clears each entry's data on the same-socket re-auth path.

The short version: a dropped connection keeps your screen, a change of identity clears it.

Tenant Scoping

Tables with the tenant() mixin are scoped by the runtime using ctx.request.subject.tenantId. Do not add duplicate tenant predicates in query executors unless you are deliberately narrowing further.

Inspect

The devtools subscription surfaces show active subscribers, recent deltas, and cache state. Use them when a query updates too often or not at all.

Cost — how large may a live query be?

Every change re-runs the query and diffs the WHOLE result against the previous one, so the cost is linear in the RESULT SIZE, not in the size of the change. Measured on diffRows:

result rows one column changed every row replaced
50 45 µs
500 480 µs
2 000 1.23 ms 1.29 ms
5 000 3.1 ms

Two things follow, and the second is the one that surprises people:

  • The curve is linear, not quadratic. Per-row cost is flat across a 100× growth (910 ns → 625 ns), so a large result gets slower in proportion and never falls off a cliff.
  • A one-column edit costs the same as replacing everything. 2 000 rows with a single change is 1.23 ms; the same 2 000 rows entirely replaced is 1.29 ms — 5 % more. The cost is the WALK, not the delta. Making your mutation smaller does not make the subscription cheaper.

So the number to design against is the RESULT SIZE. A few hundred rows is free. A 5 000-row live query costs 3.1 ms of CPU per change, per replica — fine for a dashboard that changes a few times a minute, wrong for one fed by a high-rate writer. Page the query, or narrow it with a predicate, rather than reaching for a bigger machine.

These numbers are asserted by rowPatch.perf.test.ts, so they are current rather than a note somebody wrote down once.

Fan-out — how many subscribers may one change wake?

A change wakes every subscription that reads the changed table, and the framework already collapses the work they share: one READ per distinct query, one DIFF per distinct (query, base), one no-op comparison per distinct (query, base). Fifty screens on one query cost one of each, not fifty.

What does NOT collapse is what is genuinely per subscriber: re-running the query's guards: and re-resolving row-level visibility. Those are re-run for every subscriber on every delivery, on purpose — a role revoked or a share withdrawn has to end the stream on the very NEXT delivery, not whenever a cache happens to expire — and each of them can be a database round-trip.

On every transport. A live query can leave the server three ways — the WebSocket the browser client uses, an SSE stream, and a gRPC server-streaming rpc — and all three resolve per delivery through the same code: guards re-checked before each frame, row visibility re-derived from the unfiltered base descriptor for each frame, and a revoked scope ending the stream. The transport decides how the frame is framed, never what the subject may see.

So deliveries run concurrently, up to a bound. The default is 8 in flight. Measured with 50 subscribers behind a 5 ms guard: 517 ms to serve all of them serially, 72 ms at 8 lanes.

Declare it in app.config.ts, or override per deployment with the env var — the env wins, because an operator acting on a running system outranks what the project declared:

export default {
  type: 'api' as const, name: 'api',
  reactive: { deliveryConcurrency: 16, rawReadTrackingLimit: 64 },
}
VOLTRO_REACTIVE_DELIVERY_CONCURRENCY=16 voltro serve

Raise it when your guards or row filters hit the database and you have pool headroom; set it to 1 for strictly one-at-a-time delivery. A value that is not a positive integer is ignored rather than honoured — a concurrency of 0 is a fan-out that delivers to nobody, and that is reachable through a typo in a values file. Unbounded is deliberately not an option: one round-trip per subscriber at the same instant starves the connection pool the request path shares, which is slower than serial.

Per-subscriber ordering is unaffected: a change touches each subscription exactly once. Order BETWEEN subscribers was never guaranteed.

How many subscribers fit on one node?

There is a number, it is not a constant, and which number you get depends on a property of your queries rather than of your scale. Re-derive it on your own hardware with node packages/runtime/scripts/fanout-ceiling.mjs; the figures below are the spread across three runs on a busy developer machine (12-core, macOS) at 10 matched writes per second, against a budget of 100 ms of event-loop time per second (10% of one core).

Subscriber population Marginal CPU per subscriber Subscribers per node
Shared — N clients on the SAME query (a leaderboard, a shared board), every write matches all of them 0.37–0.41 µs ≈ 25 000–27 000
Distinct — N clients each on their OWN query (where userId = me), a write matches ONE flat — per-write cost does not grow with resident subscribers not set by subscriber count

The shared case fans out by design — one read and one diff (the memoisation above), then N emits — and it is the shape that sets the ceiling. The distinct case changed shape entirely with matcher authority: a subscription whose query is a plain predicate read is woken by the predicate index alone, so a write to somebody else's row is a non-event, not a wake. Per write it costs a bucket lookup plus one delivery, regardless of how many thousands of distinct subscribers are resident. Measured: 0 of 200 subscribers whose predicate matched nothing were woken by a write on their table — a selective where buys real headroom now.

What still wakes conservatively (any change on the table), and deliberately — this list is exhaustive:

  • queries with an eager .with() spec, a setOp (union/…) or a CTE — the handler reads rows the root predicate does not describe;
  • dependsOn raw reads (the dispatcher can only re-run the descriptor, never the handler);
  • computed queries and reactivityChannel queries (their own recompute paths, unchanged);
  • oversized change events (tombstone / unrecovered — row images the matcher cannot see wake the whole table for that one event; rehydrated events are judged normally).

One thing that is easy to assume and is not true:

  • It is not 512. That constant bounds onChange LISTENERS — one per declared subscription file, reaction or aggregate, bound once at boot. Every client subscription in a process shares the dispatcher's single listener, so ten thousand of them move that count by zero.

Past the ceiling the lever is horizontal: more nodes, each carrying fewer subscribers. Change fan-out is already fleet-wide on postgres (LISTEN/NOTIFY) and mysql/mariadb (binlog), so a second node needs no extra wiring — the cost being budgeted here is the matcher and re-query CPU each node spends on ITS OWN clients.

Deltas are per-query — two queries can briefly diverge

Every subscription has its own revision line and its own delivery moment. After one write that affects two queries you hold open, the deltas arrive as two independent pushes — usually microseconds apart, but there is no cross-query transaction on the wire, and a render between the two pushes can see query A after the write and query B before it. Within ONE query you never see a partial write (a delta is computed from a committed row set); across queries, design for eventual agreement rather than instantaneous consistency — derive values that must agree atomically inside one query instead of joining two on the client.

Raw WebSocket gateways — defineWebSocket

Everything above rides the framework's subscription protocol, and that stays the answer for app realtime — live queries, optimistic patches, reconnect. A gateway exists for the other case: a FOREIGN protocol that needs a socket the framework does not speak — a Yjs provider, a legacy device fleet, an MQTT-over-WS bridge. It mounts its own upgrade path beside the rpc socket, in a *.ws.ts file discovered on both boot paths:

// gateways/yjs.ws.ts
import { defineWebSocket } from '@voltro/protocol'

export default defineWebSocket({
  path: '/gateways/yjs',
  auth: 'subject',   // REQUIRED, no default: 'subject' | 'public'
  onConnection: ({ send, close, onMessage, subject, headers, path }) => {
    const doc = attachDoc(subject!.id)
    onMessage((data) => doc.applyUpdate(data))     // binary-safe frames
    const stop = doc.onUpdate((update) => send(update))
    return () => { stop(); doc.release() }          // teardown
  },
})

The contract, in the order it protects you:

  • auth is mandatory and has no default. 'subject' runs the SAME auth chain as rpc/SSR before the upgrade — an unauthenticated caller gets 401 while the request is still plain http, and the connection is bound to the credential's expiry: when it lapses, the socket closes with application code 4001, so a foreign client can re-auth and reconnect. 'public' is a deliberate, written-down decision (a device fleet with protocol-level auth of its own).
  • Every gateway path is origin-checked at upgrade — cross-origin means 403, which closes cross-site WebSocket hijacking for your protocol exactly as for the framework's socket.
  • onConnection({ send, close, onMessage, subject, headers, path }) may return a teardown function — it runs on client disconnect, on credential expiry, and on server shutdown, so whatever the handler opened cannot outlive the socket.
  • A plain GET on a gateway path answers 426 Upgrade Required; two gateways declaring one path refuse the boot.

The boundary to keep: if your own UI needs live data, that is a query + useSubscription, never a gateway. A gateway hands you raw frames and none of the subscription protocol's guarantees — reach for it only when the CLIENT dictates the protocol.

When the api connects — web.api.connect

Every declared api opens its WebSocket at mount by default. That is 'eager', and it is what the framework has always done.

// apps/web/app.config.ts
web: { api: { connect: 'lazy' } }

'lazy' defers the connection to the FIRST hook that asks for that api — useSubscription, useMutation, useAppClient, any of them. A page that reads no data never opens a socket.

Two measurements decide whether you want it. Both are from a real browser against a voltro start:

  • interactive: 'full' is the default, and it connected regardless. A pre-rendered documentation page that subscribes to nothing opened a socket; pages set to interactive: 'none' or 'islands' opened none. So the pages paying for a connection they never use are exactly the ordinary ones.
  • An open socket keeps a dormancy-managed instance awake. isIdleNow returns false while connectedClients() > 0 (see scale-to-zero), so one browser tab left open on a pricing page prevents scale-to-zero for as long as it stays open.

'eager' remains the default because 'lazy' moves WHEN a connection error surfaces — from page load to first data use — and an app that opens its socket for a side effect (a presence ping, an inspect stream) rather than through a data hook would notice the difference. If your app subscribes on every page, the two behave identically.

See also

  • Subscribers (*.subscribe.ts) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page).
  • Streams — transient, non-database element feeds (no snapshot/reconnect replay).