Leaving a presence channel: `Unsubscribe()` doesn't untrack and re-`Register()` throws "can only be called once"
@Tr00d is already working on this.
Since Jul 3, 2026.
Assessment
This issue has not been assessed yet.
Description
Bug report
Describe the bug
Two intuitive calls for "I'm leaving a presence channel and might come back" are both wrong, and the resulting exception doesn't hint at the fix.
-
Unsubscribe()does not untrack your presence. It sends aphx_leavebut never callsUntrack(), and it does not remove the channel from the client's subscription cache:// RealtimeChannel.Unsubscribe() public IRealtimeChannel Unsubscribe() { IsSubscribed = false; NotifyStateChanged(ChannelState.Leaving); var leavePush = new Push(Socket, this, ChannelEventLeave); leavePush.Send(); NotifyStateChanged(ChannelState.Closed, false); return this; // no Untrack(); channel stays in _subscriptions }So your presence can linger as a "ghost" watcher — you have to call
Untrack()explicitly. -
Revisiting the same channel throws. Channels are cached per topic, and the cached instance keeps its
_presence, soRegister()on the reused channel throws:// Client.Channel(channelName) var topic = $"realtime:{channelName}"; if (_subscriptions.TryGetValue(topic, out var channel)) return channel; // returns the SAME instance, with _presence still set // RealtimeChannel.Register<T>(presenceKey) if (_presence != null) throw new InvalidOperationException( "Register can only be called with presence options for a channel once.");Because
Unsubscribe()left the channel in_subscriptions, navigating back gets the cached channel andRegister()throws "can only be called … once". The only way to evict it isclient.Realtime.Remove(channel):// Client.Remove(channel) if (channel.IsJoined) channel.Unsubscribe(); _subscriptions.Remove(channel.Topic);
Why it bites: "Navigate away and come back" is the most basic interaction and it breaks. The two intuitive calls (Unsubscribe() to leave, Register() again on return) are both wrong, and the exception text doesn't mention that the fix is Remove().
To Reproduce
Steps to reproduce the behavior, please provide code snippets or a repository:
- Join a presence channel, register, subscribe, track:
var ch = client.Channel("watchers"); var pres = ch.Register<Watcher>(key); await ch.Subscribe(); await pres.Track(new Watcher { /* ... */ }); - "Leave" by unsubscribing:
ch.Unsubscribe(); // presence not untracked; channel still cached - Return to the same channel and try to set presence up again:
var ch2 = client.Channel("watchers"); // returns the cached instance var pres2 = ch2.Register<Watcher>(key); // throws InvalidOperationException: // "Register can only be called with presence options for a channel once." - Observe (a) your earlier presence may still show up to other clients (ghost), and (b) re-registering throws unless you first call
client.Realtime.Remove(ch).
Expected behavior
- Leaving a presence channel should remove your presence (no ghost) — either
Unsubscribe()should untrack, or the leave/untrack relationship should be documented clearly. - Returning to a channel you previously left should "just work": either
Unsubscribe()should evict the cached channel so a freshChannel()/Register()succeeds, orRegister()on an already-registered cached channel should be idempotent (or its exception should point atRemove()).
Screenshots
N/A — reproducible from the exception and other clients' presence view.
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 behavior - Runtime: .NET 10
Additional context
Workaround: call Untrack() before leaving to remove your presence, and client.Realtime.Remove(channel) (not Unsubscribe()) so that returning to the page rebuilds a fresh channel and Register() succeeds.
JS SDK parity reference. realtime-js caches channels by topic the same way C# does — channel(topic) returns the existing instance if one exists:
// realtime-js — RealtimeClient.channel(topic)
const exists = this.getChannels().find((c) => c.topic === realtimeTopic)
if (!exists) { /* create, push, return */ } else { return exists }
The difference is what happens on close. The RealtimeChannel constructor registers an _onClose hook that auto-evicts the channel from the registry:
// realtime-js — RealtimeChannel constructor
this._onClose(() => {
this.socket._remove(this) // _remove: this.channels = this.channels.filter(c => c.topic !== channel.topic)
})
So in JS, unsubscribe() (= phoenix channel.leave()) drives the channel to closed, _onClose fires, and the channel removes itself from this.channels — a later channel(topic) returns a fresh instance. There is also no throwing "register once" guard: presence is configured via channel params + channel.on('presence', …) (repeatable), and untrack() is documented as "stop broadcasting presence while staying subscribed", not as a required step for leaving (leaving drops presence server-side).
Where C# diverges (caching is identical; the lifecycle is not):
| JS | C# | |
|---|---|---|
channel/Channel(topic) dedupes by topic |
yes (return exists) |
yes (returns cached) |
| On close/leave | _onClose → socket._remove(this) auto-evicts |
Unsubscribe() keeps it in _subscriptions |
| Re-setup presence on a reused channel | on('presence', …), repeatable |
Register() throws "can only be called once" |
| Leaving drops presence | leave() handles it |
needs a separate Untrack() to avoid a ghost |
Suggested fix (mirror JS): hook channel close to evict from _subscriptions (the equivalent of JS _onClose → socket._remove(this)), so Unsubscribe() self-cleans and a later Channel(topic) yields a fresh channel — no manual Remove(), and no "can only be called once" because the registered channel isn't reused. Secondarily, ensure Unsubscribe()'s leave actually drops presence so Untrack() isn't a required prelude.
References:
- C# (this SDK):
Realtime/RealtimeChannel.cs(Unsubscribe,Register<T>),Realtime/Client.cs(Channel,Remove,_subscriptions). - JS (parity target):
realtime-jssrc/RealtimeClient.ts(channel,_remove) andsrc/RealtimeChannel.ts(constructor_onClose → socket._remove(this),unsubscribe,untrack).
- Dominant language
- C#
- Stars
- 701
- Forks
- 106
- Avg merge
- 16h 35m
- Merged PRs (30d)
- 45
Contributor 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
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
supabase/supabase-csharp#440 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
supabase/supabase-csharp#437 ·
-
Difficulty 4/5 3-5 days Newbie friendliness 52/100
supabase/supabase-csharp#427 ·
-
supabase/supabase-csharp#409 · 1 comment · 1 assignee ·
-
area: realtime bug parity
supabase/supabase-csharp#393 · 1 assignee ·
All issues in supabase/supabase-csharp
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
-
:watch: Not Triaged 11.0 fundamentals/subsvc
Difficulty 2/5 1-3 hours Newbie friendliness 92/100
dotnet/AspNetCore.Docs#37699 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
SubtitleEdit/subtitleedit#15108 · 1 comment ·
-
area/docs-content Bug pulumi/docs
Difficulty 1/5 1-3 hours Newbie friendliness 94/100
-
agentic-workflows untriaged
Difficulty 2/5 1-3 hours Newbie friendliness 76/100