🎉 Native Google & Apple sign-in is here → read the guide
Realtime

Realtime

Realtime keeps a live WebSocket connection open between your app and Supabase so you hear about changes the instant they happen — no polling, no refresh button. A WebSocket is a persistent two-way connection: once it’s open, the server can push messages to you whenever it wants, instead of you asking over and over.

Three kinds of events travel over that connection:

  • Postgres changes — rows inserted, updated, or deleted in your database.
  • Broadcast — quick client-to-client messages that skip the database entirely (great for ephemeral things like cursor positions).
  • Presence — who is currently online.

You always work through a channel, which is like a chat room you join to receive a stream of events. You can mix all three kinds of events on a single channel.

The supabase-realtime module speaks the Phoenix WebSocket protocol with automatic reconnection (exponential backoff = the wait between reconnect attempts grows after each failure, so a flaky network doesn’t get hammered). Everything below is offered in two styles: a fluent builder (chain .onX { } calls, then .subscribe()) and Flows (a Flow is a Kotlin stream of values you collect). Pick whichever reads better in your code — they do the same thing.

Create a client

Like every feature in the library, Realtime is built from your SupabaseClient. This gives you a RealtimeClient that manages the single shared connection.

val realtime = createRealtimeClient(client)
public fun createRealtimeClient(
    supabaseClient: SupabaseClient,
    config: RealtimeConfig = RealtimeConfig(),
    engineFactory: HttpClientEngineFactory<*> = platformEngine(),
): RealtimeClient

RealtimeConfig

The optional config controls how the connection behaves — mostly how it reconnects when the network drops and how often it sends a heartbeat (a periodic ping that keeps the connection alive and proves it’s still healthy). The defaults are sensible; tune only what you need.

OptionTypeDefaultDescription
autoReconnectBooleantrueReconnect automatically when the socket drops.
initialReconnectDelayMsLong1_000First backoff delay.
maxReconnectDelayMsLong30_000Backoff ceiling.
backoffMultiplierDouble2.0Growth factor between attempts.
maxReconnectAttemptsInt00 = unlimited.
heartbeatIntervalMsLong25_000Heartbeat cadence.
connectionTimeoutMsLong10_000Connect timeout.

Connecting

Call connect() once to open the WebSocket. Because the connection lives over time, you watch its connectionState — a Flow that emits a new value every time the status changes — so your UI can show “connecting”, “reconnecting”, and so on.

realtime.connect()
 
realtime.connectionState.collect { state ->
    when (state) {
        ConnectionState.Connected -> println("Connected")
        ConnectionState.Connecting -> println("Connecting…")
        ConnectionState.Disconnected -> println("Disconnected")
        ConnectionState.Disconnecting -> println("Disconnecting…")
        is ConnectionState.Reconnecting -> println("Retry #${state.attempt} in ${state.nextRetryMs}ms")
        is ConnectionState.Failed -> println("Failed after ${state.attempts}: ${state.reason}")
    }
}
 
// Suspend until the socket is up:
realtime.awaitConnected()

If you’d rather just wait until the connection is ready before continuing, awaitConnected() suspends until the socket is up — handy at startup. The client also exposes isConnected, isConnecting, isDisconnecting, and a setAuth(token) method to update the access token on an already-open connection (useful after a user signs in or their session refreshes).

Postgres changes

This is the headline feature: get notified the moment a row changes in your database. You join a channel, then register a callback for each event type (INSERT / UPDATE / DELETE) you care about. The callback receives the row that changed.

val subscription = realtime.channel("todos")
    .onPostgresChange(table = "todos", event = PostgresChangeEvent.INSERT) { record ->
        println("New todo: $record")
    }
    .onPostgresChange(table = "todos", event = PostgresChangeEvent.DELETE) { record ->
        println("Deleted: $record")
    }
    .subscribe()
public fun onPostgresChange(
    schema: String = "public",
    table: String? = null,
    filter: String? = null,
    event: PostgresChangeEvent = PostgresChangeEvent.ALL,
    callback: suspend (JsonObject) -> Unit,
): RealtimeChannelBuilder

PostgresChangeEvent is one of INSERT, UPDATE, DELETE, ALL. The optional filter is a PostgREST-style predicate that narrows which rows you hear about, e.g. "id=eq.123" to watch a single row.

Two more callback shapes

onPostgresChange has two further overloads. Use the two-argument form when you registered event = ALL and need to know which kind of change arrived:

realtime.channel("todos")
    .onPostgresChange(table = "todos", event = PostgresChangeEvent.ALL) { event, record ->
        println("$event → $record")   // event is the PostgresChangeEvent
    }
    .subscribe()

Or the reified typed form to decode each row straight into your @Serializable model (it deserializes the JSON for you):

@Serializable
data class Todo(val id: String, val title: String, val done: Boolean)
 
realtime.channel("todos")
    .onPostgresChange<Todo>(table = "todos", event = PostgresChangeEvent.INSERT) { todo ->
        println("New todo: ${todo.title}")
    }
    .subscribe()

Prefer streams? After subscribe() you can consume typed flows: subscription.postgresInsertsFlow(), postgresUpdatesFlow(), postgresDeletesFlow().

Broadcast

Broadcast sends low-latency messages straight from one connected client to the others, without writing anything to the database. Use it for fast, throwaway data — live cursors, typing indicators, reactions — where you don’t need a permanent record.

val sub = realtime.channel("room:lobby")
    .configureBroadcast(receiveOwnBroadcasts = false, acknowledgeBroadcasts = true)
    .onBroadcast(event = "cursor") { payload ->
        println("cursor → $payload")
    }
    .subscribe()
 
sub.broadcast(event = "cursor", payload = buildJsonObject {
    put("x", 100)
    put("y", 200)
})

configureBroadcast lets you opt out of receiving your own messages (receiveOwnBroadcasts) and ask the server to confirm delivery (acknowledgeBroadcasts). Then onBroadcast listens for a named event, and broadcast sends one.

Binary broadcast

For data that is numeric, densely packed or simply not text — sensor and telemetry streams, image/screen frames, or encrypted bytes — send it raw with broadcastBinary instead of base64-encoding it into a JSON broadcast. The payload travels as a WebSocket binary frame, skipping the JSON/base64 overhead.

val sub = realtime.channel("room:telemetry")
    .configureBroadcast(receiveOwnBroadcasts = false)
    .subscribe()
 
// Send raw bytes — e.g. a packed sensor reading, a JPEG frame, or ciphertext.
sub.broadcastBinary(event = "frame", payload = bytes)
 
// Receive them as a typed Flow<ByteArray>.
launch {
    sub.binaryBroadcastFlow(event = "frame").collect { frame ->
        render(frame)
    }
}

Binary broadcasts are fire-and-forget: if the socket is momentarily disconnected the frame is dropped rather than buffered, since this data is transient. They arrive as RealtimeEvent.BinaryBroadcast (or the binaryBroadcastFlow view above).

This pairs naturally with end-to-end encryption: broadcast the ByteArray ciphertext directly — no base64 tax — and the relay only ever sees bytes it cannot read. Requires a Realtime server that supports binary payloads; older servers silently drop binary frames.

Presence

Presence tracks who is currently joined to a channel and keeps that roster in sync across everyone. It’s how you build “3 people online” or live avatar lists. Each client announces itself with track, and Presence handles cleaning up when someone disconnects.

val sub = realtime.channel("room:lobby")
    .configurePresence(key = "user-123")
    .onPresence { state -> println("Online: ${state.size}") }
    .subscribe()
 
sub.track(buildJsonObject { put("name", "Ada") })
// …later
sub.untrack()

The key uniquely identifies this client in the room. track publishes your presence data to everyone; untrack removes it. PresenceState is a Map<String, JsonObject> (presence key → data). For typed presence, use sub.presenceDataFlow<MyPresence>().

Subscriptions & flows

Every subscribe() call hands back a RealtimeSubscription — your handle to that one channel. From it you can send broadcasts, track presence, watch the subscription status, consume events as a Flow, and eventually unsubscribe.

public interface RealtimeSubscription {
    public val channel: String
    public val status: StateFlow<Status> // SUBSCRIBING, SUBSCRIBED, UNSUBSCRIBING, UNSUBSCRIBED, ERROR
 
    public fun asFlow(): Flow<RealtimeEvent>
    public fun presenceState(): PresenceState   // every member currently tracked, by key
 
    public suspend fun broadcast(event: String, payload: JsonObject)
    public suspend fun track(state: JsonObject)
    public suspend fun untrack()
    public suspend fun unsubscribe()
 
    // Low-level escape hatch — send a raw frame on the channel:
    public suspend fun send(type: SendType, event: String, payload: JsonObject? = null)
    // SendType: BROADCAST, PRESENCE, POSTGRES_CHANGES
}

presenceState() returns the current cumulative presence (empty before the first sync); send(...) is the low-level primitive the broadcast/track helpers are built on — reach for it only when you need to send a frame they don’t cover.

If you prefer the Flow style over callbacks, asFlow() gives you every event on the channel as a single stream of RealtimeEvent — a sealed type, so a when handles each case: PostgresInsert, PostgresUpdate, PostgresDelete, Broadcast, PresenceSync, PresenceJoin, PresenceLeave, SystemEvent. Convenience flows are available for each (broadcastFlow(event), presenceJoinFlow(), …) plus awaitSubscribed() to suspend until the channel is live.

One-liner helpers

When you only need one listener and don’t want the builder at all, these RealtimeClient extensions create the channel, register the listener, and subscribe in a single call.

realtime.subscribeToPostgresChanges("todos", table = "todos") { record -> /* … */ }
realtime.subscribeToBroadcast("room:lobby", event = "cursor") { payload -> /* … */ }
realtime.subscribeToPresence("room:lobby") { state -> /* … */ }

Teardown

Realtime holds an open connection and live subscriptions, so clean up when you’re done — unsubscribe from individual channels, then close down the client. Skipping this can leak the connection and keep your app awake in the background.

subscription.unsubscribe()
realtime.removeAllChannels()
realtime.disconnect()
realtime.close()