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(),
): RealtimeClientRealtimeConfig
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.
| Option | Type | Default | Description |
|---|---|---|---|
autoReconnect | Boolean | true | Reconnect automatically when the socket drops. |
initialReconnectDelayMs | Long | 1_000 | First backoff delay. |
maxReconnectDelayMs | Long | 30_000 | Backoff ceiling. |
backoffMultiplier | Double | 2.0 | Growth factor between attempts. |
maxReconnectAttempts | Int | 0 | 0 = unlimited. |
heartbeatIntervalMs | Long | 25_000 | Heartbeat cadence. |
connectionTimeoutMs | Long | 10_000 | Connect 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,
): RealtimeChannelBuilderPostgresChangeEvent 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()