Broadcast

Send low-latency messages between clients with Supabase Realtime Broadcast on SupaHost, from the browser, from a server over REST, or from the database with realtime.send().

Broadcast sends a message to every client subscribed to a channel. Messages sent by clients go through your project's Realtime server without being stored, so they suit things that only matter right now: chat messages being typed, cursor positions, live reactions, game moves, or a nudge telling clients to reload some data.

You can send Broadcast messages from three places: a connected client, any server over HTTP, or the database itself.

Listen and send from the client

A channel is identified by its topic name. Anyone subscribed to the same topic in the same project receives the same messages. Each message has an event name, so one channel can carry several kinds of messages.

This example is a team chat that shows typing indicators.

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

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!)

const room = supabase.channel('chat:design-team', {
  config: {
    broadcast: {
      self: false, // don't receive your own messages (the default)
      ack: true,   // wait for the server to confirm each send
    },
  },
})

room
  .on('broadcast', { event: 'typing' }, ({ payload }) => {
    showTyping(payload.userName)
  })
  .on('broadcast', { event: 'stopped-typing' }, ({ payload }) => {
    hideTyping(payload.userName)
  })
  .subscribe()

export async function notifyTyping(userName: string) {
  const result = await room.send({
    type: 'broadcast',
    event: 'typing',
    payload: { userName },
  })
  if (result !== 'ok') console.warn('Broadcast failed:', result) // 'timed out' or 'error'
}

Options:

  • self: true delivers your own messages back to you. Handy when every client, including the sender, renders from the same handler.
  • ack: true makes send() resolve only after Realtime has received the message. Without it, send() resolves straight away.

Use '*' as the event to receive every event on the channel: .on('broadcast', { event: '*' }, handler).

Keep payloads small. Broadcast is designed for short messages, and every message to every subscriber counts toward the project's rate limits and your instance's egress. Send an id and let clients fetch details if they need more.

Send from a server without a WebSocket

Server code often needs to announce something ("invoice 1042 was paid") without staying connected. There are two ways.

With supabase-js 2.76.0 or later, httpSend() posts the message over HTTP:

// In an API route or Edge Function
const channel = supabase.channel('team:7')
await channel.httpSend('invoice-paid', { invoiceId: 1042 })

From any language, call the Broadcast REST endpoint on your project:

curl -X POST 'https://app-one.acme.supahost.space/realtime/v1/api/broadcast' \
  -H "apikey: $SUPABASE_SERVICE_ROLE_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "messages": [
      { "topic": "team:7", "event": "invoice-paid", "payload": { "invoiceId": 1042 } }
    ]
  }'

One request can carry several messages for different topics. Use the service_role key only from server code. To send to a private channel, add "private": true to the message; the sender's token must then pass your realtime.messages insert policies (the service_role key bypasses them).

Send from the database

Realtime adds a realtime.send() function to your database. Calling it from SQL broadcasts a message, which is useful in triggers, database functions or cron jobs.

select realtime.send(
  jsonb_build_object('invoiceId', 1042, 'status', 'paid'), -- payload
  'invoice-paid',                                           -- event
  'team:7',                                                 -- topic
  true                                                      -- private channel
);

The last argument must match the channel type on the client. A private message only reaches clients that joined with private: true, and a public message only reaches public channels.

A trigger can broadcast every change to a table. Here each new chat message is sent to its room's topic:

create or replace function public.broadcast_new_message()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  perform realtime.send(
    jsonb_build_object('id', new.id, 'body', new.body, 'author_id', new.author_id),
    'new-message',
    'room:' || new.room_id::text,
    true
  );
  return null;
end;
$$;

create trigger messages_broadcast
after insert on public.messages
for each row execute function public.broadcast_new_message();

There's also realtime.broadcast_changes(), which formats a message from a trigger's TG_OP, NEW and OLD values for you. Messages sent from the database go through the realtime.messages table, so private topics need authorization policies. See Realtime authorization and Broadcasting from the database.

Broadcasting from the database is the better choice than Postgres Changes when many clients watch the same busy table. Each change is sent once to a topic, instead of being checked against every subscriber.

Clean up channels

Each client can join at most 100 channels. In single-page apps, remove channels when a view unmounts, or they pile up:

useEffect(() => {
  const room = supabase.channel(`chat:${roomId}`)
    .on('broadcast', { event: 'typing' }, handleTyping)
    .subscribe()
  return () => { supabase.removeChannel(room) }
}, [roomId])

If you see a TooManyChannels error, a component is probably creating channels without removing them. See TooManyChannels error.

Delivery guarantees

Broadcast is fire and forget. A client that is disconnected when a message is sent from another client doesn't receive it later. For anything important, write it to a table first, then broadcast so connected clients update right away. Clients that reconnect can load what they missed from the table.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us