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.
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
seenset, noinitializedflag, 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: nullmeans "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
useEventreconnects 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: trueon the descriptor if you want the recent buffer instead. - A reconnect continues from the last serial that subscription saw.
useEventdoes 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 | useEvent → missed |
| who is online in a room | usePresence |
| who is typing right now | useTyping |
| showing that the screen went stale | useEvent → status, 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?
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 deliversWhitespace 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.