In my "Getting started with Supabase" blog post, I showed three core features of Supabase: Database, Auth, and Storage. Supabase has much more to offer, and one of the features we are going to look at in this blog post is Realtime.
Supabase Realtime consists of three different mechanisms for pushing updates from the server to connected clients:
- Broadcast: a pub/sub system for pushing low-latency messages into clients.
- Presence: a shared state system for tracking who is connected and what lightweight metadata they attach.
- Postgres Changes: a database-driven system for subscribing to inserts, updates, and deletes on Postgres tables.
In the following sections, we will look at each of these three mechanisms in detail and how to use them with the supabase-js client library. Under the hood, all three of these mechanisms use WebSocket connections to deliver real-time updates from the server to the clients.
1. Broadcast ¶
Broadcast implements a traditional pub/sub model. You publish messages to a channel, and clients subscribed to that channel receive those messages in real time. The messages can be arbitrary JSON payloads, and the channel can be public or private depending on your access control needs.
It is a low-latency event delivery system. Broadcast is about delivering the latest event to all currently connected clients as soon as it happens. Use cases include notifications, telemetry, results from polling external APIs, progress events, cursor movement, and other transient signals that do not need to be stored durably in the database.
For this demo, I created a simple page that shows the current position of the International Space Station (ISS) using Broadcast. The server-side publisher fetches the latest coordinates from the open-notify API and pushes them into a channel named iss-position. The browser subscribes to that channel and renders the latest coordinates as they arrive.
Publisher ¶
To publish a message to a channel, you can make a POST request to the /realtime/v1/api/broadcast endpoint with the appropriate headers and body. The body should include the channel, event, privacy setting, and payload of the message. The demo uses a Supabase Edge Function that fetches the ISS position and broadcasts it. The broadcast is a simple HTTP POST request that looks like this:
const broadcastResponse = await fetch(
`${supabaseUrl}/realtime/v1/api/broadcast`,
{
method: "POST",
headers: {
apikey: serviceRoleKey,
Authorization: `Bearer ${serviceRoleKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
messages: [
{
topic: realtimeTopic,
event: realtimeEvent,
private: false,
payload,
},
],
}),
},
);
topic: the name of the channel to which the message will be published, for exampleiss-position.event: a string that represents the type of event, for exampleiss-update. This can be used by subscribers to filter messages. In one channel, you can have multiple event types, and clients can choose to listen to all events or specific ones.private: a boolean that indicates whether the channel is private or public. Public channels can be joined without authentication. Private channels require Realtime Authorization to be configured and clients must authenticate before subscribing.payload: the actual data you want to send to subscribers, which can be any JSON-serializable object.
This edge function is triggered by another Supabase feature: cron jobs. The cron job is set up to call the edge function every minute.
The publisher can be any application that can make HTTP requests. It does not need to be a Supabase Edge Function. You can publish from a program outside the Supabase ecosystem, as long as it can authenticate with the appropriate credentials and send the correct request to the broadcast endpoint.
In the repo, there is a Go CLI that performs the same function as the Edge Function: it fetches the ISS position and sends a broadcast message.
Subscriber ¶
On the client side, you can subscribe to a broadcast channel using the channel method of the Supabase client. You specify the channel name and set up an event listener for the desired event type. When a message is published to that channel, the event listener will be triggered with the payload of the message. In this demo, the browser subscribes to the iss-position channel and listens for iss-update events. A client can also subscribe to all events on a channel by using event: "*".
const channel = supabase
.channel("iss-position", {
config: {
private: false,
},
})
.on("broadcast", { event: "iss-update" }, (event: { payload: IssPayload }) => {
const { payload } = event;
renderPayload(payload as IssPayload);
})
.subscribe((status: RealtimeStatus) => {
setStatus(status === "SUBSCRIBED" ? "Listening" : `Realtime: ${status}`, status === "SUBSCRIBED");
});
The RealtimeStatus can be SUBSCRIBED, TIMED_OUT, CLOSED, or ERROR. The client can use that status to update the UI accordingly, for example showing a "Listening" status when subscribed and an error message if there is a problem with the connection.
Broadcast from the database ¶
Broadcast can also be triggered from the database using realtime.send() or realtime.broadcast_changes(). This allows you to emit messages from database-side logic or triggers without subscribing clients directly to Postgres Changes. Supabase currently recommends Broadcast as the more scalable way to fan out database changes to clients, while Postgres Changes is simpler but less scalable.
For trigger-based database broadcasts, the current docs require Realtime Authorization: you create RLS policies on realtime.messages, subscribe on the client with private: true, and make sure the client sends a valid auth token to Realtime. You can find more information about that in the official docs and the authorization guide.
Messages sent from the database are stored in realtime.messages for three days. This enables Broadcast Replay, but replay is currently only available for private channels and only for messages published from the database.
2. Presence ¶
Presence is different from Broadcast because the interesting data is not a stream of events. Presence is all about the current state of who is connected and what lightweight metadata they are sharing. Presence is a shared state system that lets clients track who is currently present in a channel and what small payload each client is advertising.
Use cases for Presence include online/offline indicators, collaborative cursors, participant rosters, active viewers, and other scenarios where you want to know "who is here right now?" and "what small piece of metadata is each connected client sharing right now?".
This demo uses Presence to track who is currently connected to the same page. Each client generates a random identity and shares that identity as presence metadata.
A client can track the presence state with code like this. This is similar to Broadcast, but here we use a presence object in the channel config with metadata we want to share with other subscribed clients. In this example identity.id is just a random string identifier.
const channel = supabase.channel("presence-demo", {
config: {
presence: {
key: identity.id,
},
},
});
Listening to the presence state works similarly to Broadcast. Clients subscribe to the presence channel and listen for Presence events on that channel. The sync event is emitted whenever something has changed in the presence state. That can be a new client joining, a client leaving, or a client updating their presence metadata.
channel
.on("presence", { event: "sync" }, () => {
renderPresence(channel);
})
.subscribe(async (status) => {
setStatus(status === "SUBSCRIBED" ? "Connected" : `Realtime: ${status}`, status === "SUBSCRIBED");
if (status === "SUBSCRIBED") {
await channel.track(identity);
renderPresence(channel);
}
});
Supabase emits three types of presence events:
syncwhen the overall presence state has been reconciledjoinwhen a client starts tracking presenceleavewhen a client stops tracking presence
To access the current state of who is present, you can use the presenceState() method on the channel.
const state = channel.presenceState<PresenceState>();
3. Postgres Changes ¶
Postgres Changes allows clients to subscribe to changes on Postgres tables. When a row is inserted, updated, or deleted in a table, subscribed clients receive a realtime event with the details of that change.
The current Supabase docs recommend Broadcast for most database-change use cases because it scales better. Use Postgres Changes if you want a simple way to subscribe to changes on a few tables and you don't expect a high volume of changes.
Use cases for Postgres Changes include shared task lists, chats stored as rows in tables, order status dashboards backed by SQL state, collaborative records where edits must persist, admin tools that should update when data changes, notification centers backed by database writes, inventory, workflow, or ticketing systems where the database is the source of truth.
Tables in Supabase do not publish any changes by default. To enable Postgres Changes, you need to add the table to the supabase_realtime publication.
alter publication supabase_realtime add table public.todos;
Subscriber ¶
After adding the table to the publication, you can subscribe to changes on that table from the client. The subscription code looks similar to Broadcast and Presence, but here we subscribe to the postgres_changes channel and listen for all events (*) on the public.todos table.
const channel = supabase
.channel("public:todos")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "todos" },
async () => {
await loadTodos();
},
)
.subscribe(async (status: RealtimeStatus) => {
setStatus(status === "SUBSCRIBED" ? "Connected" : `Realtime: ${status}`, status === "SUBSCRIBED");
if (status === "SUBSCRIBED") {
await loadTodos();
}
});
Whenever a row is inserted, updated, or deleted in the public.todos table, the event listener is triggered.
In this demo, the event listener simply calls loadTodos(), which re-queries the entire todos table and updates the UI with the current state of the table.
async function loadTodos() {
const { data, error } = await supabase
.from("todos")
.select("id, task, done")
.order("inserted_at", { ascending: false });
if (error) {
setStatus(error.message);
return;
}
renderTodos((data ?? []) as Todo[]);
}
Reloading the entire table creates a lot of traffic, especially when the table contains many rows. You can optimize this by inspecting the payload of the Postgres Changes event and applying only the relevant changes to the local state.
The payload of a Postgres Changes event contains the following information:
schema: the schema nametable: the table namecommit_timestamp: when Postgres committed the changeeventType: one ofINSERT,UPDATE, orDELETEnew: the new row data forINSERTand usually forUPDATEold: previous row data forUPDATEandDELETEonly when Postgres is configured to expose it. By default, Postgres Changes does not send full previous row values for every table.errors: an array of errors
If you want the full previous row for UPDATE, you need to set REPLICA IDENTITY FULL on the table. DELETE events only include the primary key columns in old.
Examples of the payload for each event type:
INSERT
{
"schema": "public",
"table": "todos",
"commit_timestamp": "2026-03-13T20:36:13.684Z",
"eventType": "INSERT",
"new": {
"id": 4,
"done": false,
"task": "test",
"inserted_at": "2026-03-13T20:36:13.682134+00:00"
},
"old": {},
"errors": null
}
UPDATE
{
"schema": "public",
"table": "todos",
"commit_timestamp": "2026-03-13T20:36:59.754Z",
"eventType": "UPDATE",
"new": {
"id": 4,
"done": true,
"task": "test",
"inserted_at": "2026-03-13T20:36:13.682134+00:00"
},
"old": {
"id": 4
},
"errors": null
}
DELETE
{
"schema": "public",
"table": "todos",
"commit_timestamp": "2026-03-13T20:37:07.435Z",
"eventType": "DELETE",
"new": {},
"old": {
"id": 4
},
"errors": null
}
Wrapping up ¶
Supabase gives you multiple options for building realtime features in your applications. Broadcast is a low-latency pub/sub system for pushing arbitrary messages to clients. Presence is a shared state system for tracking who is connected and what metadata they share. Postgres Changes is a database-driven system for subscribing to changes on Postgres tables. Each of these mechanisms has its own use cases and can be used independently or together depending on your application's needs.