Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Unsubscribe from Real-Time Updates in Supabase

Unsubscribe from Supabase real-time updates by calling supabase.removeChannel(channel) to disconnect a specific channel, or supabase.removeAllChannels() to disconnect all channels at once. In React, always call removeChannel inside the useEffect cleanup function so the subscription is removed when the component unmounts. Failing to clean up subscriptions causes memory leaks, duplicate event handlers, and eventually hits the concurrent connection limit on your Supabase plan.

What you'll learn

  • How to remove a single real-time channel with removeChannel()
  • How to remove all channels with removeAllChannels()
  • How to properly clean up subscriptions in React useEffect
  • How to handle subscription cleanup in Vue and Svelte
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner7 min read10 minSupabase (all plans), @supabase/supabase-js v2+March 2026RapidDev Engineering Team
TL;DR

Unsubscribe from Supabase real-time updates by calling supabase.removeChannel(channel) to disconnect a specific channel, or supabase.removeAllChannels() to disconnect all channels at once. In React, always call removeChannel inside the useEffect cleanup function so the subscription is removed when the component unmounts. Failing to clean up subscriptions causes memory leaks, duplicate event handlers, and eventually hits the concurrent connection limit on your Supabase plan.

Properly Cleaning Up Supabase Real-Time Subscriptions

Every real-time subscription in Supabase opens a WebSocket connection and registers event listeners. If you do not explicitly unsubscribe when a component unmounts or a page navigates away, those connections and listeners accumulate. This causes memory leaks, duplicate event handlers (showing the same message twice), and eventually exhausts the concurrent connection limit on your Supabase plan. This tutorial shows you the correct patterns for cleaning up subscriptions in every major framework.

Prerequisites

  • A Supabase project with real-time enabled on at least one table
  • @supabase/supabase-js v2+ installed
  • An existing real-time subscription you want to clean up
  • Basic understanding of React useEffect or equivalent lifecycle hooks

Step-by-step guide

1

Remove a specific channel

When you create a channel with supabase.channel('name'), store the returned reference so you can remove it later. Call supabase.removeChannel(channel) to unsubscribe from all events on that channel and close the WebSocket connection. After removal, the channel object should not be reused — create a new one if you need to subscribe again.

typescript
1import { createClient } from '@supabase/supabase-js'
2
3const supabase = createClient(
4 process.env.NEXT_PUBLIC_SUPABASE_URL!,
5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
6)
7
8// Create and subscribe
9const channel = supabase
10 .channel('messages-channel')
11 .on(
12 'postgres_changes',
13 { event: 'INSERT', schema: 'public', table: 'messages' },
14 (payload) => console.log('New message:', payload.new)
15 )
16 .subscribe()
17
18// Later: unsubscribe and remove the channel
19const status = await supabase.removeChannel(channel)
20console.log('Channel removed:', status) // 'ok'

Expected result: The channel is disconnected and no more events are received. The WebSocket connection for that channel is closed.

2

Remove all channels at once

If your application has multiple subscriptions and you need to clean up all of them — for example, when the user logs out or navigates to a completely different section — use removeAllChannels(). This removes every active channel in one call, which is simpler than tracking and removing each one individually.

typescript
1// Remove every active channel
2const statuses = await supabase.removeAllChannels()
3console.log('All channels removed:', statuses)
4// ['ok', 'ok', 'ok'] — one status per channel
5
6// Common use case: cleanup on logout
7async function handleLogout() {
8 await supabase.removeAllChannels()
9 await supabase.auth.signOut()
10 window.location.href = '/login'
11}

Expected result: All real-time subscriptions are removed and their WebSocket connections are closed.

3

Clean up subscriptions in React with useEffect

In React, the correct pattern is to create the subscription inside useEffect and call removeChannel in the cleanup function. React calls the cleanup function when the component unmounts or when dependencies change. This ensures no orphaned subscriptions survive after the component is gone.

typescript
1import { useEffect, useState } from 'react'
2
3function ChatRoom({ roomId }: { roomId: string }) {
4 const [messages, setMessages] = useState<any[]>([])
5
6 useEffect(() => {
7 // Create subscription
8 const channel = supabase
9 .channel(`room-${roomId}`)
10 .on(
11 'postgres_changes',
12 {
13 event: 'INSERT',
14 schema: 'public',
15 table: 'messages',
16 filter: `room_id=eq.${roomId}`,
17 },
18 (payload) => {
19 setMessages((prev) => [...prev, payload.new])
20 }
21 )
22 .subscribe()
23
24 // Cleanup: remove channel on unmount or roomId change
25 return () => {
26 supabase.removeChannel(channel)
27 }
28 }, [roomId]) // Re-runs when roomId changes
29
30 return (
31 <div>
32 {messages.map((msg) => (
33 <p key={msg.id}>{msg.content}</p>
34 ))}
35 </div>
36 )
37}

Expected result: Navigating away from the component or changing the roomId cleanly removes the old subscription and creates a new one.

4

Clean up subscriptions in Vue and Svelte

Vue uses onUnmounted (Composition API) or beforeDestroy (Options API) for cleanup. Svelte uses onDestroy. The pattern is the same: store the channel reference and call removeChannel in the teardown lifecycle hook.

typescript
1// Vue 3 Composition API
2import { onMounted, onUnmounted, ref } from 'vue'
3
4export function useRealtimeMessages(roomId: string) {
5 const messages = ref<any[]>([])
6 let channel: any = null
7
8 onMounted(() => {
9 channel = supabase
10 .channel(`room-${roomId}`)
11 .on('postgres_changes',
12 { event: 'INSERT', schema: 'public', table: 'messages',
13 filter: `room_id=eq.${roomId}` },
14 (payload) => messages.value.push(payload.new)
15 )
16 .subscribe()
17 })
18
19 onUnmounted(() => {
20 if (channel) supabase.removeChannel(channel)
21 })
22
23 return { messages }
24}
25
26// Svelte
27// <script>
28import { onDestroy } from 'svelte'
29
30const channel = supabase
31 .channel('messages')
32 .on('postgres_changes',
33 { event: 'INSERT', schema: 'public', table: 'messages' },
34 (payload) => messages = [...messages, payload.new]
35 )
36 .subscribe()
37
38onDestroy(() => {
39 supabase.removeChannel(channel)
40})
41// </script>

Expected result: Subscriptions are cleaned up when Vue or Svelte components are destroyed.

5

Debug subscription leaks

If you suspect subscription leaks, check how many active channels exist. The Supabase client exposes the list of active channels. Log the count periodically during development to catch leaks early. Common signs of leaks include duplicate events (same message appearing twice), increasing memory usage, and eventually hitting the connection limit.

typescript
1// Check how many channels are active
2const channels = supabase.getChannels()
3console.log('Active channels:', channels.length)
4console.log('Channel names:', channels.map((c) => c.topic))
5
6// Log channel count on every navigation (for debugging)
7window.addEventListener('beforeunload', () => {
8 const count = supabase.getChannels().length
9 if (count > 0) {
10 console.warn(`Leaving page with ${count} active channels — potential leak!`)
11 }
12})
13
14// Nuclear option: remove everything
15await supabase.removeAllChannels()

Expected result: You can see the number of active channels and identify leaks when the count grows unexpectedly.

Complete working example

use-realtime-with-cleanup.ts
1import { useEffect, useState, useRef } from 'react'
2import { createClient, RealtimeChannel } from '@supabase/supabase-js'
3
4const supabase = createClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7)
8
9interface UseRealtimeOptions {
10 table: string
11 filter?: string
12 event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'
13}
14
15export function useRealtime<T extends Record<string, any>>(
16 { table, filter, event = '*' }: UseRealtimeOptions,
17 onEvent: (payload: { eventType: string; new: T; old: T }) => void
18) {
19 const [status, setStatus] = useState<string>('disconnected')
20 const channelRef = useRef<RealtimeChannel | null>(null)
21
22 useEffect(() => {
23 const channelName = `${table}-${filter ?? 'all'}-${Date.now()}`
24 const config: any = {
25 event,
26 schema: 'public',
27 table,
28 }
29 if (filter) config.filter = filter
30
31 const channel = supabase
32 .channel(channelName)
33 .on('postgres_changes', config, (payload) => {
34 onEvent(payload as any)
35 })
36 .subscribe((s) => setStatus(s))
37
38 channelRef.current = channel
39
40 // Cleanup on unmount or dependency change
41 return () => {
42 if (channelRef.current) {
43 supabase.removeChannel(channelRef.current)
44 channelRef.current = null
45 }
46 }
47 }, [table, filter, event])
48
49 return { status }
50}
51
52// Usage example
53function MessageList({ roomId }: { roomId: string }) {
54 const [messages, setMessages] = useState<any[]>([])
55
56 useRealtime(
57 { table: 'messages', filter: `room_id=eq.${roomId}`, event: 'INSERT' },
58 (payload) => {
59 setMessages((prev) => [...prev, payload.new])
60 }
61 )
62
63 return (
64 <ul>
65 {messages.map((m) => <li key={m.id}>{m.content}</li>)}
66 </ul>
67 )
68}

Common mistakes when unsubscribing from Real-Time Updates in Supabase

Why it's a problem: Not calling removeChannel on component unmount, causing duplicate event handlers

How to avoid: Always return a cleanup function from useEffect that calls supabase.removeChannel(channel). Without it, navigating away and back creates a second subscription that fires alongside the first.

Why it's a problem: Calling channel.unsubscribe() instead of supabase.removeChannel(channel)

How to avoid: Use supabase.removeChannel(channel) to fully remove the channel from the client. channel.unsubscribe() only pauses the subscription — the channel object and WebSocket remain active.

Why it's a problem: Creating channels with the same name, causing subscription conflicts

How to avoid: Use unique channel names that include context like the table name, filter value, and a timestamp or UUID: supabase.channel(`room-${roomId}-${Date.now()}`)

Best practices

  • Always store the channel reference returned by supabase.channel() so you can clean it up later
  • Call supabase.removeChannel(channel) in useEffect cleanup functions in React
  • Use supabase.removeAllChannels() on logout or major navigation events
  • Use unique channel names to avoid conflicts between multiple subscriptions
  • Check supabase.getChannels().length during development to catch subscription leaks early
  • Await the removeChannel() promise if subsequent logic depends on the cleanup being complete

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a React app with Supabase real-time subscriptions. Show me the correct pattern for subscribing in useEffect and cleaning up with removeChannel when the component unmounts or when a dependency changes. Include a reusable hook.

Supabase Prompt

Create a reusable React hook called useRealtime that subscribes to Supabase postgres_changes on a configurable table and filter, handles INSERT/UPDATE/DELETE events, and properly cleans up the subscription on unmount. Include channel leak detection.

Frequently asked questions

What is the difference between removeChannel() and unsubscribe()?

supabase.removeChannel(channel) fully removes the channel from the client and closes the WebSocket connection. channel.unsubscribe() pauses the subscription but keeps the channel registered — you can re-subscribe later. For component cleanup, always use removeChannel().

What happens if I forget to unsubscribe?

Orphaned subscriptions continue consuming memory and receiving events. You get duplicate event handlers, increasing memory usage, and eventually hit the concurrent connection limit on your plan (200 for Free, 500 for Pro).

Does supabase.removeAllChannels() remove broadcast and presence channels too?

Yes. removeAllChannels() removes every channel regardless of type — postgres_changes, broadcast, and presence channels are all cleaned up.

Can I re-subscribe after removing a channel?

Not to the same channel object. After removeChannel(), the channel is destroyed. Create a new channel with supabase.channel() and set up the subscription from scratch.

How many concurrent real-time connections does Supabase allow?

Free plan allows 200 concurrent connections, Pro allows 500, and Team/Enterprise plans allow more. Each channel subscription from each browser tab counts as one connection.

Should I unsubscribe when the browser tab is hidden?

Not usually. The Supabase client handles background tabs efficiently. However, if you want to reduce connection usage, you can removeChannel when the tab is hidden using the Page Visibility API and re-subscribe when it becomes visible again.

Can RapidDev help manage real-time subscriptions in my Supabase application?

Yes. RapidDev can implement proper subscription lifecycle management, create reusable hooks, and ensure your application cleans up connections to avoid memory leaks and connection limit issues.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.