Home | Send Feedback | Share on Bluesky |

Realtime events with Supabase

Published: 7. April 2026  •  baas

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:

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

Official docs

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,
          },
        ],
      }),
    },
  );

index.ts

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");
  });

main.ts

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

Official docs

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,
    },
  },
});

main.ts

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);
    }
  });

main.ts

Supabase emits three types of presence events:


To access the current state of who is present, you can use the presenceState() method on the channel.

  const state = channel.presenceState<PresenceState>();

main.ts

3. Postgres Changes

Official docs

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();
    }
  });

main.ts

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[]);
}

main.ts

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:

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.