Updated on 2026-10-30 GMT+08:00

Real-Time Subscriptions (Realtime)

Scenarios

Supabase includes Realtime, a WebSocket-based suite that provides a fully automated real-time data synchronization engine without requiring you to build one yourself. It acts as a "superhighway" established between clients and a database: Whenever data in the database changes (such as INSERT, DELETE, or UPDATE operations), clients receive the updates within seconds without refreshing the page or sending requests to check for changes.

Supabase Realtime maintains persistent WebSocket connections between clients and the server. When data changes or messages are available, the server pushes updates to connected clients. Supabase Realtime provides three core features:

  • Postgres Changes: Listens for INSERT, UPDATE, and DELETE operations on database tables and notify clients when table data changes.
  • Broadcast: Sends messages directly between clients, making it ideal for notifications and collaborative editing.
  • Presence: Tracks who is online in a channel.

Common use cases:

  • Real-time data display: Automatically updates order counts, inventory levels, and monitoring metrics as business activity occurs, without repeatedly refreshing the page.
  • Collaborative editing: Allows multiple users to edit a document or whiteboard simultaneously and see one another's changes in real time.
  • Instant messaging: Supports real-time message exchange, online member lists, and typing indicators.
  • Real-time notifications: Delivers price changes, review results, and system alerts to clients as soon as they occur.

All three features are managed through @supabase/supabase-js. The SDK handles connection management and reconnection automatically, while the server uses row-level security (RLS) to determine access based on the user's authentication credentials. During development, you only need to focus on subscriptions and receiving messages.

A channel is a named communication channel in Realtime. After subscribing to a channel, a client receives only the events passing through that channel. Postgres Changes, Broadcast, and Presence all use channels to communicate.

Key Benefits of Supabase Realtime

  • No polling or manual refresh: Clients receive updates automatically when data changes, delivering an experience similar to real-time messaging applications.
  • Low barrier to entry: Connection management and reconnections are encapsulated by the SDK, requiring only a call to subscribe() to initiate subscriptions.
  • Permission isolation combined with RLS: Only row changes that the user is authorized to view are pushed, eliminating the need to write additional authentication logic.

The benefits vary by user role:

  • Individual developers (OPCs): Build professional-grade products at low cost. With just a few lines of code, developers can add real-time interactive features, such as collaborative dashboards and live charts. Persistent connections push updates when events occur, significantly reducing CPU and bandwidth consumption. Data written to Postgres can also be broadcast automatically, allowing the database to assume message queue responsibilities without requiring additional real-time component maintenance.
  • Developers who want to get started quickly: Integrate Realtime with minimal effort and receive immediate feedback. A few lines of frontend code are enough to subscribe to data changes and update pages in real time. Supabase manages connections, authentication, and broadcasting, allowing developers to focus on the frontend interface.

This section uses a real-time chat room as an example to introduce the three core features: storing messages in the database and synchronizing them in real time through Postgres Changes, using Broadcast to transmit transient states such as typing indicators, and using Presence to track the list of online members.

Configuring and Enabling Features

For performance reasons, Supabase does not push table changes to clients by default. Otherwise, every change to every table would be pushed to clients, creating excessive overhead. You must first register the tables whose changes can be published.

The registered object is called a publication, which functions as a "publication list" within the database: Only changes from tables included in this list are published; changes to tables not on the list are not pushed by Realtime. Supabase provides a predefined publication named supabase_realtime. You simply need to add your target tables to it.

The chat room example uses two tables, profiles and messages, as described in Table 1.

Table 1 Chat room tables

Table

Purpose

Key Fields

profiles

User information table, storing display names and avatars.

id (primary key, referencing auth.users), name (display name), and avatar

messages

Message table, storing chat messages.

id (primary key), room_id (room), user_id (sender, referencing profiles), content, and created_at

SQL statements for creating the tables

-- profiles: user information table
CREATE TABLE profiles (
  id         UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  name       TEXT NOT NULL,
  avatar     TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- messages: message table (It must be created after profiles because it references profiles(id).)
CREATE TABLE messages (
  id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  room_id    TEXT NOT NULL,
  user_id    UUID REFERENCES profiles(id),
  content    TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

RLS: Users must sign in to use the chat room, so access is granted only to the authenticated role. Users can update or delete only their own messages.

-- profiles: Authenticated users can read all profiles but modify only their own.
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "profiles_select_all" ON public.profiles
  FOR SELECT TO authenticated USING (true);
CREATE POLICY "profiles_update_own" ON public.profiles
  FOR UPDATE TO authenticated USING (auth.uid() = id) WITH CHECK (auth.uid() = id);

-- messages: Authenticated users can read all messages but add, modify, and delete only their own.
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "messages_select_all" ON public.messages
  FOR SELECT TO authenticated USING (true);
CREATE POLICY "messages_insert_own" ON public.messages
  FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
CREATE POLICY "messages_update_own" ON public.messages
  FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
CREATE POLICY "messages_delete_own" ON public.messages
  FOR DELETE TO authenticated USING (auth.uid() = user_id);

Add the tables whose changes you want to monitor to the publication. In this example, changes to messages are published when messages are inserted, updated, or deleted.

ALTER PUBLICATION supabase_realtime ADD TABLE public.messages; 

The monitored tables must have a primary key. Logical replication uses the primary key to identify the affected row. Without a primary key, individual rows cannot be reliably identified for change delivery.

Adding a table to the publication does not guarantee that clients will receive its changes. RLS provides an additional layer of access control by determining which rows a client is allowed to read. Supabase uses the role specified in the JWT (a sign-in credential) sent by the client to determine the client's role, rather than changing the API key.

Table 2 Client roles and visible data

Client Role

Visible Data

anon (Unauthenticated, using a publishable key)

Can only see row changes permitted for the anon role.

authenticated (Authenticated, with a sign-in credential)

Can only see row changes permitted for the authenticated role.

service_role (using a server-side key)

Can see all row changes (this role carries BYPASSRLS, directly bypassing RLS rules).

Therefore, for a change to reach a client, the table must first be included in the publication and the change must then pass the RLS access checks.

After a table is added to the publication, its changes follow this path from the database to the browser: When a row is inserted, updated, or deleted, Postgres uses its native logical replication mechanism, a built-in database mechanism for propagating changes, to emit the change. The Realtime service subscribes to the supabase_realtime publication, receives the change, and forwards it to connected browsers over persistent WebSocket connections. Adding a table to the publication allows its changes to enter this delivery pipeline. Changes to tables that are not included in the publication do not enter the pipeline and cannot be received by clients.

Postgres Changes

Postgres Changes lets you listen for changes to database tables. A client subscribes to a channel and specifies which table and operations to monitor, such as inserts, updates, and deletes. Whenever a matching change occurs, Realtime pushes the change data to the callback function over WebSocket. This section uses the chat room's messages table to show how subscribers can detect and update the interface in real time when someone sends a message (INSERT), edits a message (UPDATE), or retracts a message (DELETE).

A subscription does not take effect immediately. After subscribe() is called, the channel must complete its handshake with the server before it is considered active (when the handshake is successful, the status is SUBSCRIBED). In production code, pass a callback to subscribe() and wait for the SUBSCRIBED status before expecting to receive change events. Handle CHANNEL_ERROR and TIMED_OUT as well. The following examples omit this handling for brevity, but it should be added in production projects.

  1. Listening for INSERT

    Display new messages immediately: When someone sends a message in the chat room, a new row is inserted into the messages table. Other online members can see the new message without refreshing the page. To implement this, listen for INSERT events on the messages table. Whenever a row is inserted, Realtime pushes an INSERT event to subscribers.

    The following code creates a channel, uses on() to listen for INSERT events on the messages table in the public schema, and calls subscribe() to activate the subscription. Each time a message is inserted, the callback runs, and the entire new row (including room_id, user_id, and content) is available in payload.new.

    const channel = supabase
      .channel('new-messages')
      .on('postgres_changes', {
        event: 'INSERT',
        schema: 'public',
        table: 'messages'
      }, (payload) => {
        console.log('New message:', payload.new)
      })
      .subscribe()
  2. Listening for UPDATE

    Synchronize edited messages: When a sender edits a message they have just sent (for example, to correct a typo), all users who have the chat room open should see the updated content immediately. Some scenarios may also require highlighting which fields have changed. Listen for UPDATE events on the messages table to achieve this. Whenever a row is updated, an UPDATE event is pushed and the callback receives the data before the change (payload.old) and after the change (payload.new), allowing you to compare the differences.

    Prerequisite for retrieving old data: Set the table's REPLICA IDENTITY to FULL. Otherwise, the old values are empty. By default, PostgreSQL records only the primary key for UPDATE events, not the old values. Set REPLICA IDENTITY to FULL by running ALTER TABLE messages REPLICA IDENTITY FULL; to include the complete row of old data.

    const channel = supabase
      .channel('edited-messages')
      .on('postgres_changes', {
        event: 'UPDATE',
        schema: 'public',
        table: 'messages'
      }, (payload) => {
        console.log('Before edit:', payload.old)
        console.log('After edit:', payload.new)
      })
      .subscribe()
  3. Listening for DELETE

    Remove retracted messages immediately: When a sender retracts (deletes) a message, all clients should immediately remove it from the chat interface to avoid displaying retracted content. Listen for DELETE events on the messages table. When a row is deleted, a DELETE event is pushed, and the callback retrieves the deleted row from payload.old so that the message can be removed from the message list.

    This operation also requires REPLICA IDENTITY to be set to FULL.

    const channel = supabase
      .channel('deleted-messages')
      .on('postgres_changes', {
        event: 'DELETE',
        schema: 'public',
        table: 'messages'
      }, (payload) => {
        console.log('Message retracted:', payload.old)
      })
      .subscribe()
  4. Listening for All Events

    Sometimes you only need to know that something has changed in the chat room, for example, to refresh a local cache or record an audit log, without distinguishing between new messages, edits, and retractions. In this case, set event to * to listen for all INSERT, UPDATE, and DELETE events. Use payload.eventType in the callback to determine whether the event is INSERT, UPDATE, or DELETE, and retrieve the corresponding data from payload.new or payload.old.

    The expression payload.new || payload.old is a practical way to retrieve the relevant row data for either event type: INSERT events provide the new row data but no old row data, whereas DELETE events provide the old row data but no new row data.

    const channel = supabase
      .channel('all-messages')
      .on('postgres_changes', {
        event: '*',
        schema: 'public',
        table: 'messages'
      }, (payload) => {
        console.log('Event type:', payload.eventType)
        console.log('Change data:', payload.new || payload.old)
      })
      .subscribe()
  5. Listening for Events with Filters

    Chat rooms are organized by room, so clients typically need to receive only messages from the room they are currently in, rather than changes from other rooms. Use filter to narrow the scope, allowing the server to send only rows that match specified conditions and reducing irrelevant callbacks.

    The following example listens only for changes to rows in the messages table where room_id is room-1001. Notifications are sent only when changes match that room's filter.

    const channel = supabase
      .channel('room-1001-messages')
      .on('postgres_changes', {
        event: '*',
        schema: 'public',
        table: 'messages',
        filter: 'room_id=eq.room-1001'
      }, (payload) => {
        console.log('Room message change:', payload.new || payload.old)
      })
      .subscribe()

    The filter format is <column>=<operator>.<value>. The supported operators include those listed in Table 3.

    Table 3 Operators supported by filter

    Operator

    Meaning

    Example

    eq

    Equal to

    room_id=eq.room-1001

    neq

    Not equal to

    room_id=neq.room-1001

    gt

    Greater than

    created_at=gt.2026-01-01

    gte

    Greater than or equal to

    created_at=gte.2026-01-01

    lt

    Less than

    created_at=lt.2026-01-01

    lte

    Less than or equal to

    created_at=lte.2026-01-01

    in

    Include

    room_id=in.(room-1001,room-1002)

    The examples above demonstrate separate channels for individual scenarios. In actual projects, a single channel can use multiple .on() calls to listen for different tables and events, so you do not need to create a separate channel for each type of change.

Broadcast

Postgres Changes notifies you when the database changes, whereas Broadcast notifies you when other clients send messages. The sender publishes a message to a channel, and Realtime immediately forwards it to all online subscribers of that channel. Note that messages are sent only to clients that are online at the time; they are not persisted. The data does not pass through the database but is relayed between clients by Realtime. The "typing indicator" in a chat room is a typical use case: Typing status changes rapidly and does not need to be stored in the database, so Broadcast can relay it instantly between clients. The following example demonstrates how to send and receive Broadcast messages in a chat room.

  1. Creating a Channel and Listening for Events

    Scenario: When a user types in the input box, other users in the same room should see a "Someone is typing..." indicator. When the user stops typing, the indicator should disappear. This transient state does not need to be written to the database or retained, so Broadcast can be used to send it directly between clients.

    The following code creates a Broadcast channel and listens for typing events. channel() creates and configures the channel, on() registers the event to listen for, and subscribe() brings the channel online. In the configuration, self: false means that the sender does not receive their own Broadcast messages, preventing the sender from seeing their own typing indicator. Set it to true if you want to receive your own messages, for example, when debugging. Here, presence: false is used only to focus on the Broadcast example. In an actual chat room, Broadcast and Presence can be enabled simultaneously on the same channel.

    const channel = supabase.channel('chat:room-1001', {
      config: {
        broadcast: { self: false },  // Do not receive your own messages.
        presence: false
      }
    })
    
    channel.on('broadcast', { event: 'typing' }, (payload) => {
      console.log('Typing status received:', payload)
    })
    
    channel.subscribe()
  2. Sending Broadcast Messages

    When a user starts or stops typing, a typing Broadcast message is sent to notify other users in the same room. Use channel.send() to send the message. type is fixed as broadcast, event indicates a custom event name (used to match the listener and distinguish between message types), and payload contains arbitrary data (in this example, the username and whether the user is typing). After receiving the message, the server forwards it to online subscribers of the channel.

    await channel.send({
      type: 'broadcast',
      event: 'typing',
      payload: { name: 'Tom', typing: true }
    })

Presence

Postgres Changes handles persistent messages and real-time synchronization, whereas Broadcast handles the exchange of transient states. However, neither can answer the question, "Who is online in this channel right now?" Presence complements these features by tracking the users who are online in a channel.

Presence uses a conflict-free replicated data type (CRDT), a state synchronization mechanism that ensures all clients eventually converge on a consistent list of online users. It powers features such as online member lists and notifications like "X joined the chat." Display names and avatars are retrieved from the profiles table configured in Configuring and Enabling Features. Online status and membership events, such as joining and leaving, are synchronized only within the channel and are not stored in the database.

In a complete chat room implementation, Broadcast and Presence can operate on the same channel by configuring both under config. The following example demonstrates how to use Presence in a chat room.

  1. Joining the Channel and Registering Presence

    When a user opens the chat page, they should immediately appear in other users' online member lists. To do this, subscribe to the chat channel and call track() to register the user's presence (display name and avatar) after the subscription succeeds.

    • When creating the channel, use presence.key to assign a unique identifier to the current user. This helps distinguish multiple devices used by the same user (for example, when they are online on both a phone and a computer).
    • Call track() only after confirming status === 'SUBSCRIBED' in the subscribe() callback. Otherwise, the channel handshake may not have completed, and the presence state cannot be registered.

    In the following example, name and avatar are taken from the current user's record in the profiles table.

    const channel = supabase.channel('chat:room-1001', {
      config: { presence: { key: 'user-42' } }
    })
    
    channel.subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        await channel.track({
          name: 'Tom',
          avatar: 'image'
        })
      }
    })
  2. Synchronizing the Online Member List

    After joining the channel, the client also needs to retrieve the member list to render the online list. This is done through the sync event, which triggers when Presence has finished synchronizing state across clients. At this point, presenceState() returns the latest presence state for everyone in the channel, making it suitable for rendering the complete online member list.

    A chat room typically works as follows: Refreshes the entire list on sync, then applies incremental updates using the join and leave events described below to track who joins and leaves.

    channel.on('presence', { event: 'sync' }, () => {
      const state = channel.presenceState()
      console.log('Current online members:', Object.values(state).flat().map(p => p.name))
    })
  3. Listening for Members Joining and Leaving

    In addition to the full list, the client needs to detect when someone joins or leaves in real time, so it can display notifications such as "X joined the chat." Use the join and leave events for this purpose. The join event triggers when someone comes online, and the leave event triggers when someone goes offline. The callbacks provide the presence states of the members who joined or left, allowing the client to add or remove them from the list.

    A leave event is triggered when another user closes the page or unsubscribes as described in Unsubscribing and Cleanup. Once the connection is closed, Realtime considers that user offline and sends a leave event to the other members.

    channel.on('presence', { event: 'join' }, ({ newPresences }) => {
      console.log('Members joined:', newPresences.map(p => p.name))
    })
    
    channel.on('presence', { event: 'leave' }, ({ leftPresences }) => {
      console.log('Members left:', leftPresences.map(p => p.name))
    })
  4. Updating Your Presence State

    A member's displayed status may change, for example, when they manually switch their status to "Busy" or "Away" for others to see. To update the state, call track() again whenever it changes to overwrite the current state. Other members can detect the change through sync and join events and update their lists accordingly.

    Note that track() replaces the entire presence state. Each call must include the complete state (name, avatar, and status). Do not submit only the fields that have changed. Any omitted fields will be cleared.

    // Set status to Busy.
    await channel.track({ name: 'Tom', avatar: 'image', status: 'Busy' })
    
    // Set status back to Online.
    await channel.track({ name: 'Tom', avatar: 'image', status: 'Online' })

    Here, status is a display status used only within the channel (such as Busy or Online) and is not stored in the profiles table. The profiles table stores only persistent display names and avatars.

    Transient states such as "Someone is typing..." are not suitable for Presence because frequent track() calls introduce overhead, and the indicator needs to disappear immediately. As demonstrated in Broadcast, Broadcast is used for typing indicators.

Complete Chat Room Example: A single channel and a single call to subscribe() can support message synchronization, typing indicators, and the online member list simultaneously.

const channel = supabase.channel('chat:room-1001', {
  config: {
    broadcast: { self: false },
    presence: { key: 'user-42' }
  }
})
  // Real-time message synchronization (Postgres Changes)
  .on('postgres_changes',
    { event: '*', schema: 'public', table: 'messages',
      filter: 'room_id=eq.room-1001' },
    (payload) => updateMessageList(payload)
  )
  // Typing indicators (Broadcast)
  .on('broadcast', { event: 'typing' },
    (payload) => showTypingHint(payload)
  )
  // Online members (Presence)
  .on('presence', { event: 'sync' },
    () => renderOnlineList()
  )
  .subscribe()

The updateMessageList, showTypingHint, and renderOnlineList functions in the example are placeholders for application-specific logic and must be implemented separately.

Unsubscribing and Cleanup

When a page is closed or a component is unmounted, unsubscribe from channels and release the associated connections. removeAllChannels() disconnects all channels, while channel.unsubscribe() disconnects only the specified channel. For Presence, disconnecting also triggers a leave event for other members in the channel.

// Unsubscribe from all channels.
supabase.removeAllChannels()

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

Call the appropriate cleanup method when a component is unmounted or a page is closed to prevent connection leaks.