Skip to content

Presence undercounts late joiners. The initial presence_state snapshot is never applied #63

Description

@Tr00d

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:

  1. 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 { /* ... */ });
  2. 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 { /* ... */ });
  3. Inspect B's state after the Sync:
    Console.WriteLine(presB.CurrentState.Count);   // 1 (only "b") — expected 2 ("a" and "b")
  4. 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:

  1. 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.
  2. 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.jsPresence constructor (presence_state / presence_diff bindings, pendingDiffs, inPendingSyncState) and Presence.syncState / syncDiff; wired in via realtime-js src/RealtimePresence.tssrc/phoenix/presenceAdapter.ts (@supabase/phoenix 0.4.4).

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Fields

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions