Bug report
Describe the bug
A client that joins a presence channel after others only ever sees itself. Incremental join/leave diffs are applied correctly, but the initial presence_state snapshot sent to a newly joined client is not reflected in CurrentState. So the first tab counts everyone correctly, but every tab opened afterward undercounts — it misses everyone who was already present when it joined.
What is verified in the source:
- The init path exists.
presence_state parses to EventType.PresenceState and is dispatched to the presence instance:
// RealtimeChannel.HandleSocketMessage
case EventType.PresenceState:
PresenceSync?.Invoke(this, message); // -> _presence.TriggerSync(response)
break;
and RealtimePresence.SetState() has a dedicated branch that loads a snapshot:
else // It's a presence_state init response
{
var state = JsonConvert.DeserializeObject<PresenceStateSocketResponse<TPresenceModel>>(...);
foreach (var item in state.Payload)
CurrentState[item.Key] = item.Value.Metas!;
}
- So the functionality is implemented, yet the snapshot does not end up in a late joiner's
CurrentState — it is being lost before it reaches SetState.
The most likely drop point is the early guard at the top of HandleSocketMessage:
if (message.Ref == JoinPush?.Ref) return; // intended to skip the join ACK
The server delivers the initial presence_state as part of joining, so if that message carries the join push's ref, this guard swallows it before it can be dispatched to PresenceSync. That also explains the asymmetry: the first tab's snapshot is empty (nothing to lose), so it looks fine; a late tab's snapshot is non-empty, so dropping it loses the already-present members. (A wire capture of the presence_state frame's ref would pin this down exactly.)
Supporting evidence: the existing presence tests only exercise the diff path — two clients Track after subscribing and rely on presence_diff — there is no test where one client joins while another is already present, i.e. the snapshot case is untested.
To Reproduce
Steps to reproduce the behavior, please provide code snippets or a repository:
- Client A joins a presence channel and tracks itself:
var chA = clientA.Channel("watchers");
var presA = chA.Register<Watcher>("a");
await chA.Subscribe();
await presA.Track(new Watcher { /* ... */ });
- After A is present, client B joins the same channel and tracks itself:
var chB = clientB.Channel("watchers");
var presB = chB.Register<Watcher>("b");
await chB.Subscribe(); // server sends B a presence_state containing A
await presB.Track(new Watcher { /* ... */ });
- Inspect B's state after the Sync:
Console.WriteLine(presB.CurrentState.Count); // 1 (only "b") — expected 2 ("a" and "b")
- A's state is correct (2), B's undercounts.
Expected behavior
A client joining an occupied channel should receive and apply the initial presence_state snapshot, so CurrentState reflects all currently-present members immediately after subscribing — not just itself plus subsequent diffs.
Screenshots
N/A — observable via CurrentState.Count as above.
System information
- OS: macOS (Darwin); reproducible cross-platform
- Browser (if applies): N/A
- Version of the SDK:
Supabase.Realtime 7.0.2 (via umbrella Supabase 1.1.1); current master has the same dispatch
- Runtime: .NET 10
Additional context
There is no clean client-side workaround: the initial state is the only source of truth for already-present members, and if it is dropped, later diffs can't reconstruct it.
JS SDK parity reference. realtime-js delegates presence to @supabase/phoenix (Presence, in presence.js). It binds both presence events as ordinary channel listeners and — critically — buffers diffs until the snapshot has synced:
// @supabase/phoenix — presence.js, Presence constructor
this.channel.on(events.state, newState => { // "presence_state" (the snapshot)
this.joinRef = this.channel.joinRef()
this.state = Presence.syncState(this.state, newState, onJoin, onLeave) // reconcile to snapshot
this.pendingDiffs.forEach(diff => { // replay diffs that arrived early
this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave)
})
this.pendingDiffs = []
onSync()
})
this.channel.on(events.diff, diff => { // "presence_diff"
if (this.inPendingSyncState()) { // snapshot not synced yet?
this.pendingDiffs.push(diff) // → buffer it
} else {
this.state = Presence.syncDiff(this.state, diff, onJoin, onLeave)
onSync()
}
})
inPendingSyncState(){ return !this.joinRef || (this.joinRef !== this.channel.joinRef()) }
Why this never undercounts, and where C# diverges:
- Snapshot is always processed and authoritative. It's a plain event binding (never gated/dropped), and
syncState reconciles the full snapshot — computing both joins and leaves vs current state. C# has the equivalent init branch in SetState(), but (a) the snapshot doesn't reach it for late joiners, and (b) that branch only adds/overwrites keys (CurrentState[key] = metas) — it never removes keys absent from the snapshot, so it's a merge, not a reconcile.
- Diffs are buffered until the snapshot lands (
pendingDiffs + inPendingSyncState() + joinRef). This guarantees ordering and makes rejoin correct (a new joinRef re-enters pending state and waits for a fresh snapshot). C# has no equivalent — it applies every presence_diff immediately and treats the snapshot as an optional extra rather than the source of truth.
Suggested fix (mirror phoenix):
- Ensure
presence_state is always dispatched to the presence sync (don't let the message.Ref == JoinPush?.Ref guard swallow it — special-case presence events before the guard, or scope the guard to the join reply only).
- Add a
joinRef + pendingDiffs + inPendingSyncState() mechanism: until the snapshot for the current join is synced, buffer incoming diffs; on snapshot receipt, run a reconciling syncState then replay the buffered diffs.
- Make the init branch a full reconcile (handle leaves), like JS
syncState.
References:
- C# (this SDK):
Realtime/RealtimeChannel.cs (HandleSocketMessage, join-ref guard), Realtime/RealtimePresence.cs (TriggerSync / TriggerDiff / SetState). Adjacent to realtime#31.
- JS (parity target):
@supabase/phoenix presence.js — Presence constructor (presence_state / presence_diff bindings, pendingDiffs, inPendingSyncState) and Presence.syncState / syncDiff; wired in via realtime-js src/RealtimePresence.ts → src/phoenix/presenceAdapter.ts (@supabase/phoenix 0.4.4).
Bug report
Describe the bug
A client that joins a presence channel after others only ever sees itself. Incremental join/leave diffs are applied correctly, but the initial
presence_statesnapshot sent to a newly joined client is not reflected inCurrentState. So the first tab counts everyone correctly, but every tab opened afterward undercounts — it misses everyone who was already present when it joined.What is verified in the source:
presence_stateparses toEventType.PresenceStateand is dispatched to the presence instance:RealtimePresence.SetState()has a dedicated branch that loads a snapshot:CurrentState— it is being lost before it reachesSetState.The most likely drop point is the early guard at the top of
HandleSocketMessage:The server delivers the initial
presence_stateas part of joining, so if that message carries the join push'sref, this guard swallows it before it can be dispatched toPresenceSync. That also explains the asymmetry: the first tab's snapshot is empty (nothing to lose), so it looks fine; a late tab's snapshot is non-empty, so dropping it loses the already-present members. (A wire capture of thepresence_stateframe'srefwould pin this down exactly.)Supporting evidence: the existing presence tests only exercise the diff path — two clients
Trackafter subscribing and rely onpresence_diff— there is no test where one client joins while another is already present, i.e. the snapshot case is untested.To Reproduce
Steps to reproduce the behavior, please provide code snippets or a repository:
Expected behavior
A client joining an occupied channel should receive and apply the initial
presence_statesnapshot, soCurrentStatereflects all currently-present members immediately after subscribing — not just itself plus subsequent diffs.Screenshots
N/A — observable via
CurrentState.Countas above.System information
Supabase.Realtime7.0.2 (via umbrellaSupabase1.1.1); currentmasterhas the same dispatchAdditional context
There is no clean client-side workaround: the initial state is the only source of truth for already-present members, and if it is dropped, later diffs can't reconstruct it.
JS SDK parity reference.
realtime-jsdelegates presence to@supabase/phoenix(Presence, inpresence.js). It binds both presence events as ordinary channel listeners and — critically — buffers diffs until the snapshot has synced:Why this never undercounts, and where C# diverges:
syncStatereconciles the full snapshot — computing both joins and leaves vs current state. C# has the equivalent init branch inSetState(), but (a) the snapshot doesn't reach it for late joiners, and (b) that branch only adds/overwrites keys (CurrentState[key] = metas) — it never removes keys absent from the snapshot, so it's a merge, not a reconcile.pendingDiffs+inPendingSyncState()+joinRef). This guarantees ordering and makes rejoin correct (a newjoinRefre-enters pending state and waits for a fresh snapshot). C# has no equivalent — it applies everypresence_diffimmediately and treats the snapshot as an optional extra rather than the source of truth.Suggested fix (mirror phoenix):
presence_stateis always dispatched to the presence sync (don't let themessage.Ref == JoinPush?.Refguard swallow it — special-case presence events before the guard, or scope the guard to the join reply only).joinRef+pendingDiffs+inPendingSyncState()mechanism: until the snapshot for the current join is synced, buffer incoming diffs; on snapshot receipt, run a reconcilingsyncStatethen replay the buffered diffs.syncState.References:
Realtime/RealtimeChannel.cs(HandleSocketMessage, join-ref guard),Realtime/RealtimePresence.cs(TriggerSync/TriggerDiff/SetState). Adjacent to realtime#31.@supabase/phoenixpresence.js—Presenceconstructor (presence_state/presence_diffbindings,pendingDiffs,inPendingSyncState) andPresence.syncState/syncDiff; wired in viarealtime-jssrc/RealtimePresence.ts→src/phoenix/presenceAdapter.ts(@supabase/phoenix0.4.4).