Presence undercounts late joiners. The initial `presence_state` snapshot is never applied
Maintainers usually reply within 1 day
@Tr00d is already working on this.
Since Jul 3, 2026.
Assessment
This issue has not been assessed yet.
Description
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_stateparses toEventType.PresenceStateand is dispatched to the presence instance:
and// RealtimeChannel.HandleSocketMessage case EventType.PresenceState: PresenceSync?.Invoke(this, message); // -> _presence.TriggerSync(response) break;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 reachesSetState.
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.Realtime7.0.2 (via umbrellaSupabase1.1.1); currentmasterhas 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
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. - Diffs are buffered until the snapshot lands (
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):
- Ensure
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). - Add a
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. - 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/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).
- Dominant language
- C#
- Stars
- 702
- Forks
- 106
- Avg merge
- 2d 21h
- Merged PRs (30d)
- 51
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from supabase/supabase-csharp
-
OAuth State Error for social providers since Supabase.Gotrue v6.1.0May be free again @Tr00d claimed this 51 days ago, and no pull request is open. Openbug
supabase/supabase-csharp#377 · 4 comments · 1 assignee ·
Maintainers usually reply within 1 day
-
Postgres Changes filters are validated as anon because SetAuth is applied after channel joinMay be free again @Tr00d claimed this 63 days ago, and no pull request is open. Openarea: postgrest bug
supabase/supabase-csharp#345 · 1 comment · 1 assignee ·
Maintainers usually reply within 1 day
-
parity(functions): never send new-format API key as Authorization Bearer [from supabase-js]May be free again @Tr00d claimed this 89 days ago, and no pull request is open. Openbug parity
supabase/supabase-csharp#279 · 2 comments · 1 assignee ·
Maintainers usually reply within 1 day
-
parity(client): unrecognized sb_ API key subtypes are silently accepted, no warning [from supabase-js]May be free again @Tr00d claimed this 89 days ago, and no pull request is open. Openparity
supabase/supabase-csharp#278 · 2 comments · 1 assignee ·
Maintainers usually reply within 1 day
-
parity(realtime): add replication_ready broadcast option and system event payload [from supabase-js]May be free again @Tr00d claimed this 102 days ago, and no pull request is open. Openparity
supabase/supabase-csharp#272 · 1 assignee ·
Maintainers usually reply within 1 day
All issues in supabase/supabase-csharp
Similar issues
-
:watch: Not Triaged async-task-programming/subsvc dotnet-csharp/svc
Difficulty 1/5 Under an hour Newbie friendliness 88/100
Maintainers usually reply within 1 day
-
Python: Bug: AnnotationContent and StreamingAnnotationContent to_element writes CitationType.X, so annotations in a rendered chat history fail to parsePossibly taken A pull request linked to this issue is open or already merged. Openpython triage
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
microsoft/semantic-kernel#14590 ·
Maintainers usually reply within 3 days
-
Difficulty 1/5 Under an hour Newbie friendliness 90/100
Maintainers usually reply within 1 day
-
Needs-Triage
Difficulty 1/5 Under an hour Newbie friendliness 88/100
PowerShell/PowerShell#28194 ·
Maintainers usually reply within 1 day
-
AI Projects Client customer-reported needs-team-attention question Service Attention
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
Azure/azure-sdk-for-net#63804 · 3 comments · 1 reaction ·
Maintainers usually reply within 1 day