Events

`*.event.ts` — ephemeral fan-out to connected clients with defineEvent, ctx.events.publish and useEvent. At-most-once, live, and it tells you when it lost something.

An event is a thing that happened. It has a time and no value afterwards — where a table row has a value and no time.

That distinction decides which primitive you want, and it is the only decision here that is hard to reverse later:

You are modelling Use Because
what happened — a game started, a door opened, a terminal confirmed a payment Events (this page) nothing to store; a late arrival wants what happens next, not the history
what is — the current roster, an order's status, a document Queries + Subscriptions a late arrival wants the current value immediately
what must happen, even if we crash — charge a card, send an invoice Outbox needs persistence, retries and a delivery guarantee

If you find yourself writing a table so that a subscriber fires, you want an event.

Declare it

// events/gameLifecycle.event.ts
import { defineEvent } from '@voltro/protocol'
import { Schema } from 'effect'

export const gameStarted = defineEvent({
  name: 'games.started',
  key: Schema.Struct({ arenaId: Schema.String }),
  payload: Schema.Struct({
    gameId: Schema.String,
    gameType: Schema.Literal('evo5', 'evo6'),
    startedAt: Schema.Number,
  }),
  guards: [{ scope: 'display:read' }],
})

A *.event.ts file is browser-safe and may hold several declarations — a lifecycle's stages are one concept. Client and server import the same value, which is what makes the key and payload types identical at both ends.

key is the address, and only the address. A subscriber receives events published under a key it asked for, so the server never sends the others at all. Put in it what routes (arenaId) and nothing else — a discriminator your handler reads (gameType) is payload. Every key field fragments the subscriber set.

guards decide who may listen, in the same vocabulary a query uses, and they are checked before the subscription is registered — a refused client never holds one. The routing key is the guard input, so a resource-scoped guard ({ scope: 'arena:read', from: 'arenaId' }) sees which arena was asked for. A refusal reaches the client as the ScopeError the rpc declares.

openAccess: '<reason>' is the other answer to the same question, exactly as on a procedure. Under security.defaultDeny (the default) an event that declares neither guards: nor openAccess: refuses the boot, by name — an access decision nobody made is a hole, not a default. For an event that really is open — a public scoreboard, a status pulse — declare it and say why; the reason is what a reviewer reads and what voltro doctor prints beside the tag. Do not reach for a scope every caller already holds just to satisfy the gate: that guard reads as protection and enforces nothing.

export const scoreboardTick = defineEvent({
  name: 'scoreboard.tick',
  key: Schema.Struct({ arenaId: Schema.String }),
  payload: Schema.Struct({ score: Schema.Number }),
  openAccess: 'public scoreboard — carries no caller data',
})

The tenant is not part of the key and must never be added. It comes from the subject on both sides, so a cross-tenant delivery is impossible by construction rather than by remembering to filter.

The name shares the rpc tag space. Two declarations answering to one name fail the boot, not the first delivery.

Publish it

Only the server publishes. A client-originated event is an action that publishes — which means every publish has already passed a guard-checked, typed handler, and there is no "who may write to this channel" question to answer.

// actions/reportGameEvent.action.server.ts
export default (input, ctx) => Effect.gen(function* () {
  yield* ctx.events.publish(gameStarted, { arenaId: input.arenaId }, {
    gameId: input.gameId,
    gameType: 'evo5',
    startedAt: Date.now(),
  })
})

publish works from anywhere with a ctx: an action, a mutation, a workflow, a subscriber, a cron, a startup hook.

Inside a mutation it fires on COMMIT, and not at all on rollback. That is not a nicety: a display reacting to a game start the database rolled back happens on every constraint violation, every deadlock retry, every guard that fails after the publish line. Outside a transaction it fires immediately.

Three typed errors reach the producer, so a mismatch is one failing call rather than every deployment's handler breaking on a field that is not there: EventPayloadInvalid, EventKeyInvalid, EventPayloadTooLarge.

EventPayloadTooLarge fires at 7,500 bytes for the encoded envelope (event name + key + payload, as JSON). The ceiling is not arbitrary and it is not a transport limit to tune around: an event says that something happened, so photo.added carries a photo REFERENCE and the consumer fetches the photo through a route that can stream, cache and authorize it. A payload approaching this size is usually a read that has been pushed into a notification.

**Both handler styles publish.** `ctx.events.publish` returns an Effect, so the `Effect.gen` form above is the idiomatic one — but `await ctx.events.publish(…)` in a plain `async (input, ctx) => { … }` handler works too and resolves with the same result. It used to hand back an unrun Effect: nothing published, nothing errored, and the handler returned success.

Consume it

const { missed, status } = useEvent(gameStarted, arenaId ? { arenaId } : null, (payload) => {
  scene.switchTo('running', payload.gameId)
})

payload is typed from the descriptor — a wrong field name is a tsc error at this call site.

Everything you would otherwise hand-roll is gone, and each of these was a real bug in apps that built this on a reactive list:

  • No history on mount. A fresh subscriber gets what happens from now on. No seen set, no initialized flag, no window.
  • Exactly once, even under React StrictMode — where every effect runs twice and a naive subscription fires each handler twice, in development only.
  • A changing handler does not resubscribe. Every call site passes an inline arrow; putting it in a dependency array rebuilds the subscription on every render and loses whatever arrives in the gap.
  • A key change is a clean switch — the old subscription ends before the new one starts.
  • key: null means "not yet": no subscription, status: 'idle'. You never need a placeholder key.

Showing that it is showing stale

useEvent returns { status, missed, lastMiss }status is 'idle' | 'connecting' | 'live', so status === 'live' is your connected flag and needs no extra plumbing.

const { status, missed } = useEvent(gameStarted, { arenaId }, onStart)

// A screen nobody is standing at should say when it stopped being current.
{status !== 'live' && <Badge>reconnecting…</Badge>}
{missed > 0 && <Badge>{missed} missed — refreshing</Badge>}

This matters most where nobody is watching the tab. A wall display that loses its connection keeps rendering the last thing it received, and from across the room "stale" and "current" look identical. The difference between showing old data and showing that it is showing old data is one badge.

missed is the other half: it is COMPUTED, never estimated, so a non-zero value means deliveries provably did not arrive — worth surfacing rather than hiding, because the refresh that follows is visible anyway.

What it guarantees — read this before you build on it

  • At-most-once, best-effort, live. No persistence, no retry, no redelivery. For guarantees use the outbox; this is the other axis.
  • Ordered per publishing instance per key. Not globally per key — two instances publishing the same key have no shared counter, and we do not promise an order we cannot keep.
  • Guards are re-checked on EVERY delivery, exactly as a live query's are. A resource-scoped guard ({ scope: 'arena:read', from: 'arenaId' }) runs its resolver each time, so un-sharing a resource or ending a membership stops the stream at the next delivery — the client is told, not silently skipped. What this does not catch is a ROLE revoked on the subject itself: those scopes were captured when the subscription opened. That half is covered by the credential bound below.
  • A subscription cannot outlive the credential that authorized it. When the session carries an expiry, the stream ends at it — and useEvent reconnects immediately, which is a NEW request, so the subject is resolved afresh and the guards run again for real. Still entitled: it continues and your app sees nothing. No longer entitled: the reconnect is refused, loudly. You write no reconnect handling for this; it is the existing retry doing its job. Note the limit precisely — this bounds EXPIRY, not revocation.
  • Payloads are capped at 7,500 bytes of encoded envelope, on every dialect. An event says something happened, so carry a photo reference, not a photo.

missed is a number, not a feeling

When deliveries are lost, you are told how many and why:

useEvent(gameStarted, key, handler, {
  onMissed: ({ count, reason }) => resyncFromServer(count),   // reason: 'buffer' | 'resume'
})

Every delivery carries a serial, and the server keeps the highest it has seen — so a loss is arithmetic (what you were owed, minus what could be replayed), never an estimate. buffer means your client fell behind and the oldest were dropped; resume means a reconnect asked for messages older than the server still holds.

This matters more than it sounds. Silence is the one outcome nothing can be built on: a display cannot tell "no game started" from "I missed the start signal".

Reconnects resume; mounts do not

These read as one contradiction — never replay history against never lose a message — and they are two different questions:

  • A first attach starts empty. Set rewind: true on the descriptor if you want the recent buffer instead.
  • A reconnect continues from the last serial that subscription saw. useEvent does this for you, including after a deploy or a proxy timeout.

The buffer is deliberately small — tens of messages, minutes. Anything larger is a durable queue, and the framework already has one.

Triggering a workflow from an event

triggerWorkflow({ on: gameStarted, workflow: 'postGameReport' })

on: takes the descriptor and reads its name, so renaming the event moves this call site with it. The older string form (event: 'games.started') still works and is going away: with a string, a rename leaves the trigger matching nothing and the workflow simply never runs again — nothing errors, which is the same silence this whole primitive exists to remove.

Reaching HTTP receivers too

An event can also be delivered to subscribed webhook targets — the third audience of the same declaration:

export const orderPaid = defineEvent({
  name: 'orders.paid',
  key: Schema.Struct({ orderId: Schema.String }),
  payload: Schema.Struct({ total: Schema.Number }),
  webhook: { description: 'An order was paid', version: 2 },
})

One publish now reaches connected clients, every matching workflow trigger, and every subscribed HTTP target. Without this an app that does both declares the thing twice, in two shapes, and the two drift.

The webhook: block is namespaced because its settings mean nothing to the other audiences — a top-level rateLimit would read as if it throttled client delivery, which it does not — it is a ceiling on webhook deliveries only. Requires @voltro/plugin-webhooks; absent, it costs nothing.

Across instances

Local delivery always works. For fan-out across replicas the event rides postgres LISTEN/NOTIFY or @voltro/plugin-broadcast (Redis / NATS), exactly like change events — and a broker outage degrades cross-replica delivery without touching local subscribers.

Each declared event gets its own channel (<namespace>:events:<name>, where the namespace defaults to your app's name — see broadcast), and a replica subscribes to it only while it actually has a local subscriber. This matters as soon as one event is much busier than the others: on a single shared channel every replica receives, decodes and tracks every event of every peer, including the ones it serves no clients for. With five replicas and a high-rate event whose subscribers all sit on one of them, four were doing that work and discarding the result.

Nothing to configure — it follows from the declaration. The one operational consequence: during a rolling deploy replicas on different framework versions use different channel names, so cross-replica delivery is degraded for the length of the rollout. Local delivery on each replica is unaffected throughout.

Measured against socket.io

Same machine, same Redis, same topology, back to back:

p50 (median of 5) p50 range p99 (median of 5) p99 range
Voltro, publisher → Redis → subscriber 0.68 ms 0.58–0.86 7.01 ms 4.50–13.36
socket.io + @socket.io/redis-adapter 1.31 ms 1.16–1.73 4.52 ms 4.33–7.83

Five runs each, alternating, on one machine. The p50 ranges do not overlap — that is a real ~2x advantage. The p99 ranges DO overlap, so the tail difference is weaker evidence than the medians.

An earlier version of this table reported one run each and claimed "32% faster at the median, 44% worse at the tail". Both numbers were noise: the median advantage is nearer 2x and the tail gap is inside the overlap. A single measurement presented as a fact is the defect this framework spends its time removing, and it was committed here.

Where our tail comes from, measured rather than guessed. Splitting the publish path: our own code — building the envelope, the Effect fiber, handing off — costs p50 0.056 ms / p99 0.444 ms. Waiting for Redis to acknowledge costs p50 1.17 ms / p99 6.43 ms. So roughly 0.4 ms of a 7 ms tail is ours; the rest is the broker round-trip, which socket.io pays too. There is no code-level tail defect to fix here — on this machine the number is dominated by Docker's network stack.

The script is in the repo (scripts/bench/socketio-cross-replica.mjs) so the number can be re-taken rather than believed. It is not a test: keeping a competitor in the dependency tree to hold a number green is the wrong trade.

Topology is what makes this a comparison at all. Two server instances share one Redis; the client hangs off instance B and every emit is issued on instance A. The first version measured socket.io on a plain localhost websocket and came out 3x faster — which proved nothing, because that is one hop and this is two through a broker.

Hosted products (Firebase, Pusher, Ably) are deliberately absent. Measuring them honestly needs their accounts, regions and tiers, and a wrong number about someone else's product is worse than no number.

The hard questions, and our answers

the question the answer here
Does a client learn that deliveries were missed? Yes — missed is COMPUTED from per-origin serials against a watermark, never estimated
Can a late arrival tell "nothing happened" from "I was not listening"? Yes — every delivery carries prior, the watermark before it was accepted
Is a subscription authorized, or only the connection? Per subscription, on the routing key, re-checked per delivery
Does a subscription outlive the credential that authorized it? No — bounded by the credential's verified expiry, cookie AND bearer
Does the link heal itself after a broker outage? Yes — no restart, no app-side retry, no resubscribe
Does a degraded network lose messages or only slow them? Only slows them — 7x the median latency, zero loss
Does fan-out cost grow with subscriber count? No — 0.027 µs per delivery, flat from 1 to 100
Are channels typed, or strings? Typed — a rename is a compile error
Is a declared event nothing publishes reported? Yes, at boot, reading sibling apps in the workspace
Is cross-replica fan-out separated per app by default? Yes — the namespace derives from the app name

Every row is enforced by a test (realtimeProperties.test.ts) that fails if the proof behind it disappears. A property may not be claimed here without something in the repository that demonstrates it.

Why this is not a benchmark against other products. A table of our measured numbers beside someone else's published ones is not a comparison — it is two things in a row. Benchmarking a hosted competitor honestly needs their accounts, regions, tiers and retry policies, and a wrong number about someone else's product is worse than no number. What decides a choice anyway is not the microseconds; it is whether the system answers these questions at all. Each answer above is checkable against this repository by anyone, which is the opposite of a claim.

What you can build — and what to reach for

what you want to build reach for
a list that updates as rows change useSubscription
a huge list without loading all of it useWindowedSubscription
an edit that appears before the server confirms useMutation (optimistic is derived)
a signal with no row behind it — a game start, a trigger defineEvent
a 60 Hz value where only the newest matters defineEvent + delivery: 'latest'
knowing a delivery was provably missed useEventmissed
who is online in a room usePresence
who is typing right now useTyping
showing that the screen went stale useEventstatus, or useConnectionStatus
fan-out across replicas broadcastPlugin()
durable work with progress a client can watch useWorkflow
an in-app inbox useInbox
delivering an event to a third party @voltro/plugin-webhooks
gating who may subscribe guards: on the event
an upload whose progress the UI follows useUpload

This table is a TEST (realtimeCapabilities.test.ts), not a claim: each row asserts its primitive is still exported, so a capability that loses its primitive to a rename goes red in CI rather than being discovered by whoever tries to build it.

Three of these are the ones people usually reach for wrongly. A value that changes many times a second is an EVENT, not a row — writing it to a table wakes every subscriber of every query reading that table, and each pays a full re-diff. "Who is online" is presence rather than a table, because the answer is ephemeral and per-connection. And "did anything get lost" has a real answer (missed), computed rather than estimated, so you do not have to build a heartbeat of your own to find out.

The four costs side by side

Every number below is asserted by a test in the repo, not quoted from a report — each surface has a *.perf.test.ts that prints what it measured.

primitive operation cost scales with
Events ctx.events.publish 4.3 µs (~232 k/s) nothing — flat
Events delivery to a subscriber 0.027 µs subscribers, linearly and cheaply
Presence a heartbeat 0.16 µs nothing — flat
Presence a roster read, 10 k members 547 µs the ROOM
Records a live query re-diff, 5 000 rows 3 062 µs the RESULT SET
Broadcast cross-replica, real Redis p50 1.1 ms · p99 11.2 ms the network

The comparison is the useful part. Publishing an event costs about a thousandth of what re-diffing a large live query does, and that ratio — not either number — is what should decide between them. A value that changes at 60 Hz belongs in an event; the same value written to a table wakes every subscriber of every query reading it, and each pays the full walk.

Two of the four are flat and two are not. A publish and a heartbeat cost the same whatever the load, so they scale by adding replicas. A roster read is linear in the room and a live-query diff is linear in the RESULT SET — including when one column of one row changed, because the cost is the walk rather than the patch. Those are the two numbers to keep an eye on as an app grows.

Cross-replica adds milliseconds, not microseconds, and that is a network crossing rather than framework overhead. Under an injected 20 ms ± 10 jitter it becomes p50 26 ms / p99 89 ms; adding a 50 KB/s ceiling makes it p50 188 ms — and in every one of those runs, all 200 messages arrive. Degradation costs latency, never messages.

Throughput — the numbers, and where this is the wrong primitive

Measured on one core, publish path only:

bus.publish, 1–100 subscribers ~1.5µs (~670,000/s)
bus.publish, 1000 subscribers ~3.1µs (~325,000/s)
ctx.events.publish (validation + size gate + bus) ~4.1µs (~240,000/s)

Across replicas, measured over a real Redis with two processes — 200 of 200 delivered, no loss:

p50 p95 p99 max
1.67 ms 2.91 ms 6.58 ms 9.95 ms

That is the broker round trip plus both bus hops. It is the number that matters for a display in another pod, and it is the one to compare against a hosted realtime service — where the same hop is a network round trip to someone else's region.

Fan-out is nearly free. One subscriber and a hundred cost the same — the per-publish work dominates, not the delivery loop. What you pay per subscriber is the wire encode on its own subscription, not anything in the bus.

For a game lifecycle — eight stage events per game, one publish each — that is several orders of magnitude of headroom. Even 100 players at 60Hz (6,000 events/s) sits at ~2.5% of one core.

Where it stops being the right tool

Not at a throughput number, but at a semantic one: this primitive guarantees at-most-once delivery of every message, with gap accounting. For a 60Hz stream of positions or cursors, that guarantee costs something and buys nothing — nobody needs frame 1 once frame 2 has arrived. You want last-value-wins state, not a delivery log.

delivery: 'latest' — when only the current value matters

Declare it, and the framework stops treating a superseded value as a loss:

export default defineEvent({
  name: 'player.moved',
  key: Schema.Struct({ arenaId: Schema.String }),
  payload: Schema.Struct({ playerId: Schema.String, x: Schema.Number, y: Schema.Number }),
  access: 'authenticated',
  delivery: 'latest',
})
each (default) latest
A slow subscriber keeps the newest, is told how many it lost receives the current value
Gap reporting missed is computed and delivered none — nothing was lost
Server retention up to 64 messages / 5 minutes one value
Reconnect replays what is retained, reports the shortfall hands over the current value

The distinction is semantic, not performance. latest is not "the fast mode": it changes what a missing message means. Choosing it for a stream where each delivery matters drops the ones in between; choosing each for a per-frame stream makes a slow client work through a backlog to reach a state it could have had immediately, and report a "loss" that was never one.

The test: would a deployment be wrong to miss one?

`delivery: 'latest'` cannot be combined with `webhook`, and the declaration is refused. `latest` says a superseded delivery did not matter — but a webhook delivery is a durable side effect at a third party, and one already sent cannot be superseded. A 60Hz event with an HTTP audience is also 60 deliveries per second *per subscribed target*, and the webhook rate limit **defers** the excess as pending rows rather than dropping it, so the symptom is a growing table rather than an error anyone would look at. Publish the high-rate event for clients and a separate, coarser one for the outside world.

Still worth avoiding

  • Payloads over a few hundred bytes at high rate. The size gate stops you at 7,500 bytes, and long before that the wire encode per subscriber becomes the cost. Send a reference.
  • Per-frame data as an event at all. Even under latest, 60Hz of positions is 60 encodes per second per subscriber. Coalescing on the client and publishing at 10–20Hz is usually indistinguishable to a human and an order of magnitude cheaper.

The honest rule: use an event when a deployment would be wrong to miss one. If missing one is fine because the next one supersedes it, either declare delivery: 'latest' or model it as state — a table, or a value the client replaces.

Testing

import { testEventBus } from '@voltro/testing'

const events = testEventBus()
const display = events.subscribe(gameStarted, { arenaId: 'a1' })
await events.publish(gameStarted, { arenaId: 'a1' }, { gameId: 'g1', gameType: 'evo5', startedAt: 0 })
expect(display.received).toEqual([{ gameId: 'g1', gameType: 'evo5', startedAt: 0 }])

// Force a loss deterministically instead of racing a queue:
events.skipSerials(gameStarted, { arenaId: 'a1' }, 5)

It drives the real bus and the real publish path — validation, the size gate and serials all behave as they do in production — so a test cannot pass on a payload the server would reject.

Evolving a payload

Clients decode against their own copy of the schema. Adding a field is safe. Removing a required field breaks clients still running the old bundle, loudly, at decode time — which is better than a silent undefined in a handler, and worth knowing if you ship to devices that do not reload for months. Treat those deployments as additive-only.

Anti-pattern: events as rows

If you have this, replace it:

// ✗ an events TABLE, reconstructed into "new" on the client
const { data } = useSubscription('app', 'realtime.list', { limit: 500 })
const seen = useRef(new Set()); const initialized = useRef(false)
useEffect(() => { /* mark everything seen on the first pass, then diff */ }, [data])

Three bugs in nine lines, and every consumer has to get all three right: the seen set, the initialized flag (without it, loading the page replays 500 old events into a live system), and limit (a silent ceiling — nothing tells you when more than 500 arrive between renders). The table also grows forever and holds rows nobody reads twice.

Migrating is mechanical: declare the event, replace the insert with ctx.events.publish, replace the hook with useEvent, and drop the table.

What defineEvent refuses, and why

defineEvent({ name: 'orders paid', … })              // ✗ whitespace — see below
defineEvent({ name: 'orders.paid', guards: [], … })  // ✗ enforces nothing
defineEvent({ name: 'x', openAccess: '', … })        // ✗ the reason is the point
defineEvent({ name: 'x', openAccess: 'why', guards: [{ scope: 's' }], … })  // ✗ two decisions
defineEvent({ name: 'x', webhook: { rateLimit: { perMinute: 0 } } })  // ✗ never delivers

Whitespace in a name is a broker-level failure, not a style rule. The name becomes a broker SUBJECT segment. NATS refuses a subject containing whitespace and delivers nothing — with no error on the publishing side. An app that works on Redis therefore stops working when the transport changes, silently and in production only. Use a dot to namespace: orders.paid.

guards: [] is refused because it reads at the call site as if the event were protected and enforces nothing — the empty list never reaches the check. An event with no decision at all does not boot under security.defaultDeny; declare openAccess: '<why>' for a deliberately open one. An empty openAccess reason is refused — the reason is the reviewable half of the decision — and openAccess + guards together are refused: two decisions say the event is protected AND open.

rateLimit: { perMinute: 0 } defers every delivery forever. There is no "unlimited" spelling — omit rateLimit for no ceiling. version: 0 would make a subscriber pinned to 1 read the event as behind, the opposite of what a bump is for.

See also

Subscriptions · Outbox · Subscribers · Streams