Skip to main content
Supabase Realtime is a globally distributed service that enables real-time communication between clients. Built on Elixir and Phoenix Channels, it provides low-latency messaging, user presence tracking, and database change notifications.

Architecture

Realtime consists of several key components:
  • WebSocket Server: Elixir-based server handling persistent connections
  • Postgres Replication: Listens to database changes via logical replication
  • Phoenix Channels: Manages pub/sub messaging and presence
  • Global Distribution: Servers deployed across multiple regions for low latency
  • Authorization: Integrates with Supabase Auth and RLS policies
Realtime can handle millions of concurrent connections and is designed for horizontal scaling.

Core Features

Realtime provides three main features:

1. Broadcast

Send and receive low-latency messages between clients. Use cases: Chat messages, cursor tracking, collaborative editing, game events

2. Presence

Track and synchronize user state across clients. Use cases: Online user lists, typing indicators, active participants

3. Postgres Changes

Listen to database changes in real-time. Use cases: Live data updates, notification systems, activity feeds

Broadcast

Broadcast enables low-latency messaging between connected clients:

Basic Broadcast

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(supabaseUrl, supabaseKey)

// Create a channel
const channel = supabase.channel('room-1')

// Subscribe to broadcast messages
channel
  .on('broadcast', { event: 'cursor-pos' }, (payload) => {
    console.log('Cursor moved:', payload)
  })
  .subscribe()

// Send a broadcast message
channel.send({
  type: 'broadcast',
  event: 'cursor-pos',
  payload: { x: 100, y: 200 }
})

Chat Application

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

// Listen for messages
channel
  .on('broadcast', { event: 'message' }, ({ payload }) => {
    displayMessage(payload)
  })
  .subscribe()

// Send a message
function sendMessage(text: string) {
  channel.send({
    type: 'broadcast',
    event: 'message',
    payload: {
      user: currentUser,
      text: text,
      timestamp: new Date().toISOString()
    }
  })
}

Collaborative Cursors

const channel = supabase.channel('document:123')

channel
  .on('broadcast', { event: 'cursor' }, ({ payload }) => {
    updateCursor(payload.userId, payload.x, payload.y)
  })
  .subscribe()

// Track mouse movement
document.addEventListener('mousemove', (e) => {
  channel.send({
    type: 'broadcast',
    event: 'cursor',
    payload: {
      userId: currentUser.id,
      x: e.clientX,
      y: e.clientY
    }
  })
})

Broadcast from Database

Trigger broadcasts directly from PostgreSQL:
-- Create a function to broadcast messages
create or replace function broadcast_message()
returns trigger
language plpgsql
as $$
begin
  perform realtime.send(
    jsonb_build_object(
      'event', 'new_message',
      'payload', row_to_json(new)
    ),
    'message',
    'chat-room',
    false -- public (true = private)
  );
  return new;
end;
$$;

-- Attach trigger to table
create trigger on_message_created
  after insert on messages
  for each row
  execute function broadcast_message();

Presence

Presence tracks and synchronizes user state across all connected clients:

Basic Presence

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

// Track presence state
const presenceState = channel.track({
  user_id: user.id,
  username: user.username,
  online_at: new Date().toISOString()
})

// Listen for presence changes
channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    console.log('Online users:', state)
  })
  .on('presence', { event: 'join' }, ({ key, newPresences }) => {
    console.log('User joined:', newPresences)
  })
  .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
    console.log('User left:', leftPresences)
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await presenceState
    }
  })

Online User List

const channel = supabase.channel('lobby')

channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    const users = Object.keys(state).map(key => state[key][0])
    
    displayOnlineUsers(users)
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await channel.track({
        user_id: currentUser.id,
        username: currentUser.username,
        avatar_url: currentUser.avatar_url,
        status: 'online'
      })
    }
  })

Typing Indicators

const channel = supabase.channel('chat-room')
let typingTimeout: NodeJS.Timeout

channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState()
    const typingUsers = Object.keys(state)
      .map(key => state[key][0])
      .filter(user => user.typing && user.user_id !== currentUser.id)
    
    displayTypingIndicator(typingUsers)
  })
  .subscribe()

// Update typing status
input.addEventListener('input', async () => {
  await channel.track({ ...currentUser, typing: true })
  
  clearTimeout(typingTimeout)
  typingTimeout = setTimeout(async () => {
    await channel.track({ ...currentUser, typing: false })
  }, 1000)
})

Postgres Changes

Subscribe to database changes in real-time:

Listen to All Changes

const channel = supabase
  .channel('db-changes')
  .on(
    'postgres_changes',
    { event: '*', schema: 'public', table: 'posts' },
    (payload) => {
      console.log('Change received:', payload)
    }
  )
  .subscribe()

Listen to Inserts

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

Listen to Updates

const channel = supabase
  .channel('post-updates')
  .on(
    'postgres_changes',
    { event: 'UPDATE', schema: 'public', table: 'posts' },
    (payload) => {
      console.log('Updated post:', payload.new)
      console.log('Old values:', payload.old)
      updatePostInUI(payload.new)
    }
  )
  .subscribe()

Listen to Deletes

const channel = supabase
  .channel('post-deletes')
  .on(
    'postgres_changes',
    { event: 'DELETE', schema: 'public', table: 'posts' },
    (payload) => {
      console.log('Deleted post:', payload.old)
      removePostFromUI(payload.old.id)
    }
  )
  .subscribe()

Filter by Column Values

// Listen to changes for specific user
const channel = supabase
  .channel('my-posts')
  .on(
    'postgres_changes',
    {
      event: '*',
      schema: 'public',
      table: 'posts',
      filter: `author_id=eq.${user.id}`
    },
    (payload) => {
      console.log('My post changed:', payload)
    }
  )
  .subscribe()

Multiple Subscriptions

const channel = supabase
  .channel('multi-changes')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'posts' },
    handleNewPost
  )
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'comments' },
    handleNewComment
  )
  .on(
    'postgres_changes',
    { event: 'UPDATE', schema: 'public', table: 'posts' },
    handleUpdatedPost
  )
  .subscribe()

Channel Management

Channel States

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

channel.subscribe((status) => {
  switch (status) {
    case 'SUBSCRIBED':
      console.log('Connected!')
      break
    case 'CHANNEL_ERROR':
      console.log('Channel error')
      break
    case 'TIMED_OUT':
      console.log('Connection timed out')
      break
    case 'CLOSED':
      console.log('Connection closed')
      break
  }
})

Unsubscribing

// Unsubscribe from a channel
await channel.unsubscribe()

// Remove all channels
await supabase.removeAllChannels()

// Remove a specific channel
await supabase.removeChannel(channel)

Authorization

RLS Policies for Realtime

Realtime respects Row Level Security policies:
-- Only allow users to receive their own messages
create policy "Users can read own messages"
  on messages for select
  to authenticated
  using (auth.uid() = user_id);

-- Realtime will automatically filter changes based on this policy

Private Channels

// Require authentication for channel access
const channel = supabase.channel('private-room', {
  config: {
    broadcast: { self: true },
    presence: { key: user.id }
  }
})

Custom Authorization

-- Create a function to check channel access
create or replace function authorize_channel(
  requested_channel text,
  user_id uuid
)
returns boolean
language plpgsql
security definer
as $$
begin
  -- Custom authorization logic
  return exists (
    select 1
    from channel_members
    where channel = requested_channel
    and member_id = user_id
  );
end;
$$;

Performance Optimization

Throttling Updates

import { debounce } from 'lodash'

const sendCursorUpdate = debounce((x, y) => {
  channel.send({
    type: 'broadcast',
    event: 'cursor',
    payload: { x, y }
  })
}, 50) // Max 20 updates per second

document.addEventListener('mousemove', (e) => {
  sendCursorUpdate(e.clientX, e.clientY)
})

Connection Pooling

// Reuse channels when possible
const channels = new Map()

function getChannel(name: string) {
  if (!channels.has(name)) {
    channels.set(name, supabase.channel(name))
  }
  return channels.get(name)
}

Selective Subscriptions

// Only subscribe to relevant data
const channel = supabase
  .channel('posts')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'posts',
      filter: `category=eq.${currentCategory}`
    },
    handleNewPost
  )
  .subscribe()

Best Practices

Use Filters

Filter subscriptions to reduce unnecessary data transfer.

Throttle Updates

Debounce high-frequency events to prevent overwhelming clients.

Clean Up

Always unsubscribe from channels when components unmount.

Handle Errors

Implement proper error handling and reconnection logic.

Common Patterns

Live Query

function useLivePosts() {
  const [posts, setPosts] = useState([])

  useEffect(() => {
    // Initial fetch
    supabase
      .from('posts')
      .select('*')
      .then(({ data }) => setPosts(data))

    // Subscribe to changes
    const channel = supabase
      .channel('posts')
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'posts' },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setPosts(prev => [...prev, payload.new])
          } else if (payload.eventType === 'UPDATE') {
            setPosts(prev => prev.map(p => 
              p.id === payload.new.id ? payload.new : p
            ))
          } else if (payload.eventType === 'DELETE') {
            setPosts(prev => prev.filter(p => p.id !== payload.old.id))
          }
        }
      )
      .subscribe()

    return () => {
      channel.unsubscribe()
    }
  }, [])

  return posts
}

Optimistic Updates

async function updatePost(id: number, updates: any) {
  // Update UI immediately
  setPosts(prev => prev.map(p => 
    p.id === id ? { ...p, ...updates } : p
  ))

  // Persist to database
  const { error } = await supabase
    .from('posts')
    .update(updates)
    .eq('id', id)

  // Realtime will sync the change across all clients
  if (error) {
    // Revert on error
    console.error('Update failed:', error)
  }
}

Next Steps

Broadcast

Learn about low-latency messaging

Presence

Track user state across clients

Postgres Changes

Subscribe to database changes

Authorization

Secure your real-time channels