@ai-matrx/realtime
The ONE Supabase Realtime layer for Matrx clients. Hand-writing .channel( in a
consuming app is a code-review defect.
pnpm add @ai-matrx/realtime
Why this package exists
"We struggle with it so much across our system. We've struggled with it trying to set it up for messaging, trying to set it up for notes. To this day, after hundreds of hours of development, the realtime in our note system still doesn't work perfectly." — Arman, 2026-08-29
Realtime is not hard because subscribing is hard. Subscribing is four lines. It is hard because of eight things that are invisible until they hurt, each of which was learned here the expensive way — a browser that froze, a note that lost an edit, a chat that showed "Ada is typing…" for an hour. This package writes those eight things down ONCE, as code, and makes every consumer use them.
That list is the doctrine below. It replaces the prose version that used to live in
matrx-frontend's supabase-realtime skill; the skill now points here.
The doctrine
1. Suppress your own echoes — revision-monotonic FIRST, timestamp second
Supabase sends you your own writes. Postgres Changes has no "not to self" option. The
echo of your UPDATE arrives 50–500ms after your REST response already returned the fresh
row. By the time it lands, any "save in flight" flag has been cleared. Flag-only suppression
always misses the echo.
What an unsuppressed echo does: the echo of save N arrives while the user typed toward N+1 → dirty-local ≠ server → a false conflict → the conflict UI does O(n²) diff work per render → the next save clears it → its echo re-arms it. Self-sustaining main-thread saturation.
What OVER-suppression does (the phantom conflict, 2026-09-13): every platform table's
version moves on EVERY update — a desktop sync stamping file_path, an ingest job writing
metadata. Same actor, identical content, our write pending: the old ledger called it an echo,
the client kept the old number, and its next compare-and-swap missed into a conflict dialog
for a row whose text equalled the client's own base. The client had suppressed the one event
that carried the number it needed.
The package's write ledger classifies every payload, by revision first (the row's
version, read from revisionField, default version):
- R1. Older revision than we hold → stale.
- R2. The revision an unsettled write of ours will produce, with matching content → own-echo (the echo that beats the REST response, recognized by number).
- R3. An already-settled revision with matching content → own-echo; divergent → remote.
- R4. Anything newer → remote, always — same actor, same content, pending write or not. Delivering it is harmless; swallowing it is the next phantom conflict.
Only when no revision is present does the timestamp doctrine apply:
- Strictly older
updated_atthan what we hold → stale, dropped (merging it regressesupdated_at, which fails the next save's optimistic lock into a sticky false conflict). - Content we are known to have written and not yet settled → own-echo.
- Equal
updated_at→ own-echo only if the content fingerprint also matches; a same-millisecond collaborator write still lands. - Unparseable timestamps → deliver. Degrade to delivering, never to silently dropping.
Register your own writes with their number: ledger.begin({ …, revision: expectedVersion + 1 }),
ledger.settle(ticket, { revision: response.version }), ledger.observe({ …, revision }).
And pair it with @ai-matrx/data's guardedUpdate({ rebase }), so a bump the client still
missed (a slept tab) is retried against the current number instead of shown as a conflict.
Broadcast echoes are handled by the envelope: every send carries this client's session id and the receiver drops its own.
2. Echo suppression is ON by default; opting out is the discouraged path
echoSuppression: false exists for the rare consumer that genuinely needs its own events back
over the wire. It is never the default and never the fix for something else.
3. At-least-once in, exactly-once out
Resubscribes, rejoins, and a consumer that broadcasts a row it also INSERTs all deliver the
same logical event twice. Supply an eventKey (or accept the default) and the package dedups.
4. Handlers never run on the socket path
A Supabase channel calls your handler on the socket's callback path. A handler that dispatches to Redux, diffs content, or makes an RPC therefore executes back-to-back with no yield when a burst arrives — and the tab freezes. Every historical Matrx browser lockup is a variant of this; a background tab waking with 500 queued events is the classic trigger.
The package puts a bounded, ordered, yielding queue between the socket and your handler. Order is preserved per channel (an async handler is awaited before the next event starts), the drain yields to the event loop every few milliseconds, and depth is capped — an overflow sheds the oldest work, says so, and triggers a reconcile, which is the correct recovery for "we could not keep up".
5. Reconnect means RE-READ. Realtime has no replay.
Every event that happened while the socket was down is gone forever. A reconnect without a
re-read leaves a permanently wrong screen that looks perfectly healthy. So every channel
declares onBackfill, and the package calls it on every recovery path: reconnect, tab
wake, network restore, and queue overflow. A channel with no onBackfill gets a loud warning.
Backoff is jittered and has a stability reset: the attempt counter resets only after the
channel has held for 30s, never on SUBSCRIBED. Resetting on subscribe makes a flapping
channel cycle at the 1s floor forever, firing a full catch-up fetch every cycle.
6. Tab sleep and app background are the normal failure, not the exotic one
A backgrounded tab gets throttled until the socket idles out; a suspended phone comes back
with a WebSocket that still looks open and will never deliver another event. Both map onto
the same wake path: reconcile, and re-join anything not demonstrably healthy. Web uses
visibilitychange; React Native uses AppState (@ai-matrx/realtime/native).
7. One place names channels
Supabase's client.channel(topic) dedupes by topic: two features that both picked
"messages" silently share one channel and lose each other's bindings. Declare a namespace
instead — a second, different declaration of the same name throws.
Separately, React 19 double-invokes effects and removeChannel is async, so a remount can be
handed the previous mount's still-joined channel and .on(...) throws "cannot add
postgres_changes callbacks after subscribe()".
🚨 THE TOPIC-SEMANTICS SPLIT — the two transports do not mean the same thing by "topic", and the package treats them differently.
- Postgres Changes: the topic is only a client-side connection key. The server pushes on the
declared filter, whatever the channel is called. So every connection attempt gets a unique
instance topic (
mx:notes:u1#7), which makes the remount collision above impossible for free. - Broadcast and presence: the topic IS the room. It is the routing key the server fans
messages out on. A unique instance topic there does not cost nothing — it costs everything:
two browsers that both declared
mx:room:1land in#1and#2and never see each other. (That shipped in 0.1.x and was found by@ai-matrx/messaging; fixed in 0.2.0.)
So a channel carrying broadcast or presence puts its declared topic on the wire verbatim, and
the manager keeps one underlying channel per declared topic, ref-counted: a second open() of
that topic attaches its bindings to the same channel, and the channel is removed only when the
last holder closes. The remount collision is handled there by deferring the real teardown one
turn, so React's cleanup-then-re-run re-attaches instead of racing an async removeChannel.
Consequences worth knowing before you open two holders on one topic:
- They share ONE channel config. The first one's
echoSuppressionand presence key win, and a disagreement is a loudchannel.room.config-mismatchdiagnostic — not a silent surprise. - They hear each other locally. Same-tab holders share one client id and the channel is opened
broadcast.self:false, so the wire never echoes one holder's frame to its sibling. The manager hands everysend()directly to the other open holders of that topic (same envelope, same dedup; never back to the sender; raw-wire channels excluded). Cross-tab delivery is unchanged. - Exactly ONE of them is the tab leader.
handle.isLocalLeader()is true for the longest-open holder only, andonLeaderChange(isLeader)fires when leadership moves because the leader closed. Run once-per-tab work (autosave, CRDT host election) on the leader — two holders each concluding "I am alone, I am host" is the two-autosaves, last-snapshot-wins bug. - They share ONE presence entry (one key per channel), so a topic needs exactly ONE presence
publisher. A surface that only READS the roster — a typists label, an online-dot list — passes
presence: { publish: false }(orpublish: falsetouseTyping): it sees everyone and stops fighting over the entry. Two publishers is a loudchannel.room.presence.multiple-publisherswarning, not a mystery. - Echo suppression still applies per client, so one holder does not see its sibling's own
send()— that is the point of echo suppression, not a room failure. What both holders see is every remote peer's message. - A holder that brings Postgres Changes bindings to an already-joined room forces a rebuild (those
bindings ride the JOIN payload and cannot be added later). It is announced as
channel.room.rebuild, and every holder reconciles through its backfill door. Open a mixed channel's holders together at mount and it never happens.
8. Choosing the transport
- Postgres Changes — durable rows where RLS must gate delivery (notes, files, tasks). The default for entity sync.
- Broadcast — ephemeral or high-frequency data with no authorization nuance (CRDT updates, typing, signaling). Never manually broadcast a row you also INSERT — every subscriber gets it twice.
- Presence — who's-here rosters. Ephemeral only.
- One channel per feature. Postgres Changes, broadcast, and presence can share one; do not run three.
- If the table carries
updated_by(the platform_stamp_actortrigger), every UPDATE already identifies its editor — you may not need presence at all.
9. A foreign peer owns its wire — say so with wire: {mode:"raw"}
Every broadcast this package sends is wrapped in the Matrx envelope
({v, cid, eid, ts, data}), and that envelope is what makes echo suppression and dedup possible
at all. Some channels do not get to choose: the other end is a separately released program
that reads a payload shape it defined, and re-shaping the wire under it would simply stop the peer
hearing us with nothing failing loudly at either end. The case that produced this mode is the
Chrome extension's matrx-extension-bridge:<userId> channel — the deployed extension parses a
bare BridgeEnvelope ({direction, action, requestId, payload, timestamp}) with a Zod schema.
const bridge = defineChannelNamespace({
namespace: "extension-bridge",
parts: ["userId"],
description: "matrx-extend ↔ matrx-frontend RPC bridge (foreign wire).",
foreignTopic: "matrx-extension-bridge", // the peer's topic root, used verbatim
});
manager.open({
topic: bridge.topic({ userId }),
wire: { mode: "raw", acceptEchoFromSelf: true },
broadcast: [{ event: "FRONTEND_RPC", onMessage: ({ data }) => route(data) }],
eventKey: (_source, payload) => `${payload.direction}:${payload.requestId}`,
onBackfill: () => {},
});
Raw mode means exactly two things, in both directions: send puts your payload on the wire
verbatim (byte-identical to a hand-rolled channel.send), and receive never reads an
envelope — not even from a payload that happens to look enveloped. BroadcastMessage.enveloped
is always false there.
It is a named downgrade, and it is never silent. Two protections ride the envelope:
- Echo suppression used
cid, and a raw wire has none. Supplywire.isOwnMessagewhen the peer's shape identifies its sender; otherwise setwire.acceptEchoFromSelf: trueto state in code that the server'sbroadcast.self:falseis the only protection. Supplying neither is legal and produceschannel.raw.echo-unguarded(warn); the explicit opt-out still announces itself aschannel.raw.echo-accepted(info), because the diagnostics stream must show which channels run unguarded. - Dedup keyed off
eid. Without one, every arrival is a distinct key. SupplyeventKey— it receives the raw payload — or takechannel.raw.dedup-unguarded(warn).
foreignTopic is the topic half of the same idea. The namespace is still declared and still
collision-checked; only the mx: root is replaced by the peer's. Renaming a foreign topic to
mx:… does not migrate anything — it puts this client in a room of one while the peer keeps
listening where it always did.
Raw mode is transitional by intent: it exists so both ends of a foreign wire can ride this
package today. When the peer ships an envelope-aware release, the change is deleting wire from
the spec — coordinate the two releases and record the order in the peer's channel contract.
Silent-zero-events checklist
A subscription that compiles but receives nothing has one of these:
- The table is not in the
supabase_realtimepublication. - RLS filters the events (realtime is RLS-authorized — that is a feature, but grants gate delivery).
REPLICA IDENTITY FULLis missing and you need old-row data or RLS-on-DELETE.- The table is not yet in the PostgREST/realtime schema cache (
notify pgrst, 'reload schema').
Using it
React
import { RealtimeProvider, useChannel } from "@ai-matrx/realtime/react";
import { defineChannelNamespace } from "@ai-matrx/realtime";
// Once, beside the feature. Declaring twice with a different shape throws.
export const notesChannel = defineChannelNamespace({
namespace: "notes",
parts: ["userId"],
description: "Note rows for one user",
});
function App({ supabase, userId }) {
return (
<RealtimeProvider client={supabase} actorId={userId}>
<Notes userId={userId} />
</RealtimeProvider>
);
}
function Notes({ userId }) {
const { status } = useChannel({
topic: notesChannel.topic({ userId }),
postgresChanges: [
{
event: "*",
schema: "workbench",
table: "notes",
rowId: (row) => String(row.id),
fingerprint: (row) => String(row.content),
onChange: ({ row }) => applyNote(row),
},
],
// Realtime has no replay. This is how the gap gets closed.
onBackfill: () => refetchNotes(userId),
});
return <StatusDot status={status} />;
}
Handlers may be fresh closures on every render — the subscription is keyed on the channel's shape, not on handler identity, and the latest handler is always the one that runs.
One provider per app — and how a sibling package rides it
<RealtimeProvider> builds exactly ONE manager, and that manager owns the write ledger that
makes an optimistic write's own echo recognizable. A second provider anywhere in the tree is a
second ledger and a second set of channels, so one manager's own writes look REMOTE to the
other. Apps mount one.
A PACKAGE that does realtime work of its own (@ai-matrx/messaging, @ai-matrx/meet) must
therefore ride the host's manager when the host has one, and mount its own only in an app that
wires none:
const hostProvidesRealtime = useIsRealtimeProviderMounted();
if (hostProvidesRealtime) return <Runtime {...props} />;
return <RealtimeProvider client={client}><Runtime {...props} /></RealtimeProvider>;
Ask useIsRealtimeProviderMounted(), never useRealtimeManager() !== null — the manager is
also null while a mounted provider waits for its Supabase client, and a package that guessed
from it would mount a provider and then tear it, and every channel under it, back down the
instant the host's client arrived.
Redux middleware, thunks, and other non-React owners
Rule 4 prefers a Redux middleware as a feature's subscription owner — a component effect
re-subscribes on identity churn and multiplies channels across mounts. Middleware cannot call
a hook, so the provider publishes its manager and non-React owners read it. This is a
pointer to the ONE manager the provider built; constructing your own in middleware is a second
write ledger, and every optimistic write of yours would then classify as remote and cost a
refetch.
import { subscribeToRealtimeManager } from "@ai-matrx/realtime";
// Opens on arrival, closes on departure, RE-OPENS on the new manager when the
// provider rebuilds one (the signed-in user changed). Returns a stop function.
const stop = subscribeToRealtimeManager(() => ({
topic: notesChannel.topic({ userId: currentUserId() }),
postgresChanges: [ /* … */ ],
onBackfill: () => store.dispatch(fetchNotes()),
}));
currentRealtimeManager() and onRealtimeManagerChange(listener) are the primitives underneath.
Reading currentRealtimeManager() once at module load is almost always wrong: it is null
before auth resolves, and the manager is rebuilt (and the old one disposed, with every channel it
owned) when the signed-in user changes. Prefer subscribeToRealtimeManager.
The spec is a factory so the owner captures the current user id / dispatch at open time
rather than at module load.
Private channels (Database Broadcast)
A topic authorized against realtime.messages RLS — how realtime.broadcast_changes()
from a Postgres trigger delivers rows only to the entitled users — declares private:
manager.open({
topic: schedulerChannel.topic({ userId }),
private: true,
broadcast: [{ event: "UPDATE", onMessage: ({ data }) => apply(data) }],
onBackfill: () => refetchSchedules(),
});
The package creates the channel with config.private: true and authorizes the socket
(client.realtime.setAuth()) before subscribing. That second step is not optional and is
the quietest failure in this whole surface: without it the join is refused, the channel
receives nothing forever, and it reports itself perfectly healthy. A client that cannot
authorize gets channel.private.unsupported; a token that will not attach gets
channel.private.auth-failed.
Autosave coexistence
Register the write on the shared ledger before the optimistic update, and the echo is suppressed while a genuine collaborator write still lands:
const manager = useRealtimeManager();
const ticket = manager.ledger.begin({
table: "workbench.notes",
id: note.id,
fingerprint: draft.content,
});
try {
const saved = await saveNote(draft);
manager.ledger.settle(ticket, { updatedAt: saved.updated_at, fingerprint: saved.content });
} catch (error) {
manager.ledger.abandon(ticket);
throw error;
}
ledger.classify(...) returns own-echo | remote | stale plus localWritePending, so an
optimistic write, its echo, and a real remote write are all distinguishable.
Presence and typing
const { others } = usePresence({
topic: roomChannel.topic({ roomId }),
presence: { state: { userId, name } },
});
const { typists, onKeystroke, stopTyping } = useTyping({
topic: chatChannel.topic({ conversationId }),
identity: { userId, name },
});
A crashed client cannot leave a ghost participant (heartbeat + local-clock expiry), and "X is typing…" cannot stick (typing is a renewed lease, never a latch that waits for a stop message that may never arrive).
React Native / Expo
import { NativeRealtimeProvider, useChannel } from "@ai-matrx/realtime/native";
<NativeRealtimeProvider client={supabase} actorId={userId}>
{children}
</NativeRealtimeProvider>;
Identical hooks, with the AppState seam already wired: background → foreground reconciles
and re-joins. react-native is an optional peer; the web entries never load it.
Diagnostics
import { realtimeDiagnostics } from "@ai-matrx/realtime";
// or: const snapshot = useRealtimeDiagnostics();
Live channels, subscription state, events/minute, and the numbers nobody ever had:
suppressedEcho, deduped, stale, queueDepth, queueDropped, handlerErrors,
backfills. Realtime bugs are meant to be observed, not guessed at.
Entry points
| Entry | Contains | "use client" |
|---|---|---|
@ai-matrx/realtime | The framework-free core: manager, write ledger, topics, presence/typing runtimes, queue, backoff, dedup, diagnostics | no (safe from RSC, Redux middleware, workers, node) |
@ai-matrx/realtime/react | RealtimeProvider, useChannel, usePresence, useTyping, useRealtimeDiagnostics, useRealtimeHealth — plus the whole core | yes |
@ai-matrx/realtime/native | NativeRealtimeProvider, createReactNativeEnvironment — plus the whole React binding | yes |
Zero runtime dependencies. react and react-native are optional peers. The package never
imports @supabase/supabase-js: the client is accepted structurally, and a compile-time proof
against the real declarations lives in src/core/supabase-shape.test.ts.
Migrating off hand-rolled channels
- Declare a namespace for the feature; delete the hand-written topic string.
- Replace the
.channel(...)block withuseChannel(ormanager.openin middleware). - Move the catch-up read into
onBackfill— if the module had none, this is the bug you are fixing. - Delete the module's own echo/dedup/backoff code. Keeping a second copy beside the package is the named failure, not the safe option.
uniqueChannelTopiclives HERE.@ai-matrx/data/dbre-exports it (data 0.6.3) so its existing call sites keep working — but a migrating feature stops calling it at all: a namespace names the channel and the manager decides the wire topic per the topic-semantics split in rule 7. A hand-rolled broadcast or presence channel that was callinguniqueChannelTopicitself was splitting its own room — that is a bug you are fixing, not behavior to preserve.
License
MIT