Back
Supabase

Realtime with Supabase: WebSockets and Subscriptions

Implement realtime features with Supabase. Broadcast, Presence, Postgres Changes, and a practical chat example.

Francisco ZapataWritten by Francisco Zapata
February 16, 202610 min read
Realtime with Supabase: WebSockets and Subscriptions

Realtime features transform static applications into dynamic, interactive experiences. Supabase offers a powerful Realtime system built on Phoenix Channels (Elixir) that provides three primitives: Postgres Changes, Broadcast, and Presence.

Realtime System Architecture

Supabase Realtime is built on Elixir and Phoenix, technologies known for handling millions of concurrent connections. The system operates through WebSockets:

Client ←→ WebSocket ←→ Supabase Realtime Server ←→ PostgreSQL
                                    ↕
                              Other Clients

The Three Primitives

1. Postgres Changes: Listen to INSERT, UPDATE, DELETE on tables

2. Broadcast: Send ephemeral messages between connected clients

3. Presence: Track online user state

Postgres Changes

The most used feature: listening to database changes in real time.

Requirements

First, enable replication for the tables you need:

ALTER PUBLICATION supabase_realtime ADD TABLE posts;
ALTER PUBLICATION supabase_realtime ADD TABLE comments;

Listen to All Changes

const channel = supabase.channel('db-changes')

channel
  .on('postgres_changes',
    { event: '*', schema: 'public', table: 'posts' },
    (payload) => {
      console.log('Change type:', payload.eventType)
      console.log('New data:', payload.new)
      console.log('Old data:', payload.old)
    }
  )
  .subscribe()

Filter by Event Type

const channel = supabase.channel('new-posts')
  .on('postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'posts' },
    (payload) => {
      console.log('New post:', payload.new.title)
      addPostToList(payload.new)
    }
  )
  .subscribe()

Filter by Column

const channel = supabase.channel('my-posts')
  .on('postgres_changes',
    {
      event: '*',
      schema: 'public',
      table: 'posts',
      filter: 'author_id=eq.d0e8c77e-4f2a-4b5c'
    },
    (payload) => {
      console.log('Change in my posts:', payload)
    }
  )
  .subscribe()

Broadcast

Broadcast allows sending messages between clients without persisting them to the database. Ideal for cursors, typing indicators, game positions, etc.

Send and Receive Messages

const channel = supabase.channel('room-1')

channel.on('broadcast', { event: 'message' }, (payload) => {
  console.log('Message received:', payload.payload)
})

channel.subscribe((status) => {
  if (status === 'SUBSCRIBED') {
    channel.send({
      type: 'broadcast',
      event: 'message',
      payload: {
        user: 'Francisco',
        text: 'Hello everyone!'
      }
    })
  }
})

Typing Indicator

const typingChannel = supabase.channel('chat-typing')

typingChannel.on('broadcast', { event: 'typing' }, ({ payload }) => {
  showTypingIndicator(payload.user)
})

typingChannel.subscribe()

let typingTimeout
function onInputChange() {
  clearTimeout(typingTimeout)
  typingChannel.send({
    type: 'broadcast',
    event: 'typing',
    payload: { user: currentUser.name }
  })
  typingTimeout = setTimeout(() => {
    typingChannel.send({
      type: 'broadcast',
      event: 'stop-typing',
      payload: { user: currentUser.name }
    })
  }, 2000)
}

Presence

Presence tracks who is connected to a channel in real time. It automatically handles connections and disconnections.

Track Online Users

const presenceChannel = supabase.channel('online-users')

presenceChannel.on('presence', { event: 'sync' }, () => {
  const state = presenceChannel.presenceState()
  const onlineUsers = Object.values(state).flat()
  updateOnlineUsersList(onlineUsers)
})

presenceChannel.on('presence', { event: 'join' }, ({ key, newPresences }) => {
  console.log('Joined:', newPresences)
})

presenceChannel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
  console.log('Left:', leftPresences)
})

presenceChannel.subscribe(async (status) => {
  if (status === 'SUBSCRIBED') {
    await presenceChannel.track({
      user_id: currentUser.id,
      username: currentUser.name,
      avatar: currentUser.avatar,
      online_at: new Date().toISOString()
    })
  }
})

Complete Example: Realtime Chat

Let us combine all three primitives to create a functional chat:

class RealtimeChat {
  constructor(roomId, user) {
    this.roomId = roomId
    this.user = user
    this.channel = supabase.channel(`chat:${roomId}`)
    this.onMessage = null
    this.onPresenceChange = null
    this.onTyping = null
  }

  connect() {
    this.channel.on('postgres_changes',
      {
        event: 'INSERT',
        schema: 'public',
        table: 'messages',
        filter: `room_id=eq.${this.roomId}`
      },
      (payload) => {
        if (this.onMessage) this.onMessage(payload.new)
      }
    )

    this.channel.on('broadcast', { event: 'typing' }, ({ payload }) => {
      if (this.onTyping && payload.userId !== this.user.id) {
        this.onTyping(payload)
      }
    })

    this.channel.on('presence', { event: 'sync' }, () => {
      const state = this.channel.presenceState()
      if (this.onPresenceChange) {
        this.onPresenceChange(Object.values(state).flat())
      }
    })

    this.channel.subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        await this.channel.track({
          userId: this.user.id,
          username: this.user.name
        })
      }
    })
  }

  async sendMessage(text) {
    await supabase.from('messages').insert({
      room_id: this.roomId,
      user_id: this.user.id,
      text: text
    })
  }

  sendTyping() {
    this.channel.send({
      type: 'broadcast',
      event: 'typing',
      payload: { userId: this.user.id, username: this.user.name }
    })
  }

  disconnect() {
    this.channel.unsubscribe()
  }
}

Cleanup and Disconnection

Always clean up subscriptions when you no longer need them:

await supabase.removeChannel(channel)
await supabase.removeAllChannels()

In reactive frameworks:

// Vue 3
onUnmounted(() => {
  supabase.removeChannel(channel)
})

// React
useEffect(() => {
  const channel = supabase.channel('...')
  return () => supabase.removeChannel(channel)
}, [])

Performance Considerations

1. Filter on the server: Use filters in postgres_changes to receive only the data you need

2. Limit channels: Do not create unnecessary channels; group related events

3. Throttle broadcasts: For cursors or positions, send updates every 50-100ms

4. Always unsubscribe: Open WebSocket connections consume server resources

Conclusion

Supabase Realtime transforms your applications with three complementary tools. Postgres Changes for data synchronization, Broadcast for ephemeral communication, and Presence for user presence. With these primitives you can build everything from simple notifications to complex collaborative applications.

Comments (0)

Leave a comment

Be the first to comment