---
name: supabase-flutter-realtime
description: >-
Builds realtime features with supabase_flutter v3: live lists of table rows with
typed stream(), reacting to inserts, updates and deletes with
onTableChanges, broadcast messages between clients, presence, and channel
status and cleanup. Use when the user wants live updates, a chat, cursors,
an online indicator or notifications of database changes.
---
# Realtime with supabase_flutter
## Choose the API
| Need | Use |
| ------------------------------------------------------- | ------------------------------------------------ |
| The current rows of a table, kept up to date | `table(X.table).stream(primaryKey: [...])` |
| Only some columns of those rows | `table(X.table).streamOnly(...)` |
| Every insert, update or delete as an event | `channel.onTableChanges(X.table)` |
| Messages between clients that are not stored in a table | `channel.onBroadcast` and `sendBroadcastMessage` |
| Who is online | `channel.track` and `onPresenceSync` |
## Rules
- Every listener is a `Stream`. There are no `callback:` parameters.
- Create every `onTableChanges`, `onBroadcast` and presence stream before
`channel.subscribe()`. A postgres changes listener added after it throws a
`StateError`.
- Cancel the subscriptions and call `supabase.removeChannel(channel)` when the
feature is closed. A forgotten channel keeps receiving messages.
- Use the typed table definitions from the generated schema, not table names
as strings.
- Database changes only arrive for tables in the `supabase_realtime`
publication, and only for rows the user may select under row level security.
- Create streams once, in `initState` or a field of `State`, never in
`build()`, or every rebuild opens a new subscription.
## A live list of rows
`stream()` fetches the matching rows, then applies every change, and emits
the whole list each time:
```dart
final Stream<List<BooksRow>> books = supabase
.table(Books.table)
.stream(primaryKey: [Books.id])
.filter(Books.authorId.eq(2))
.order(Books.createdAt.desc())
.limit(50);
```
`filter()` takes one operator: `eq`, `neq`, `lt`, `lte`, `gt`, `gte`,
`inFilter`, `like`, `ilike`, `matchRegex`, `imatchRegex`, `isNull`,
`isTrue`, `isFalse` or `isDistinct`, optionally negated with `not()`. A filter
composed with `&` or `|` throws an `ArgumentError`.
`order()` takes the same orderings as a query, `Books.title` or
`Books.createdAt.desc()`. A stream keeps `NULL`s last ascending and first
descending, so `.nullsFirst()`, `.nullsLast()`, or an ordering by anything but
a stored column, such as a computed field, throws an `ArgumentError`.
To receive only some columns:
```dart
final titles = supabase
.table(Books.table)
.streamOnly(primaryKey: [Books.id], columns: [Books.title])
.map((rows) => [for (final row in rows) row.read(Books.title)]);
```
```dart file
import 'package:flutter/widgets.dart';
class BookList extends StatefulWidget {
const BookList({super.key});
@override
State<BookList> createState() => _BookListState();
}
class _BookListState extends State<BookList> {
final _books = Supabase.instance.client
.table(Books.table)
.stream(primaryKey: [Books.id]);
@override
Widget build(BuildContext context) {
return StreamBuilder<List<BooksRow>>(
stream: _books,
builder: (context, snapshot) {
if (snapshot.hasError) return Text('${snapshot.error}');
final books = snapshot.data ?? const [];
return Column(children: [for (final book in books) Text(book.title)]);
},
);
}
}
```
## Reacting to changes
```dart
final channel = supabase.channel('books-by-author-2');
final subscription = channel
.onTableChanges(
Books.table,
event: PostgresChangeEvent.insert,
filters: [Books.authorId.eq(2)],
)
.listen((change) {
if (change case TypedPostgresInsert(:final newRow)) {
print('New book: ${newRow.title}');
}
});
channel.subscribe();
```
Without `event`, the stream carries `TypedPostgresInsert`,
`TypedPostgresUpdate` and `TypedPostgresDelete`, so switch over all three.
The old row of an update or delete only carries the primary key, unless the
table has `replica identity full`. `onTableChangesOnly(…, columns: [...])`
asks the server for fewer columns.
## Broadcast
```dart
final channel = supabase.channel('room-1');
channel
.onBroadcast(event: 'cursor')
.listen((payload) => print('Cursor at ${payload['x']}'));
channel.subscribe();
await channel.sendBroadcastMessage(event: 'cursor', payload: {'x': 30});
```
Pass `RealtimeChannelConfig(self: true)` as `options` to `supabase.channel`
to receive your own broadcasts, and `private: true` for a channel guarded by
realtime authorization policies.
## Presence
```dart
final channel = supabase.channel('room-1');
channel.onPresenceSync.listen((_) {
print('Online: ${channel.presenceState().length}');
});
channel.onStatusChange.listen((change) async {
if (change.status == RealtimeSubscribeStatus.subscribed) {
await channel.track({'user_id': 'user-1'});
}
});
channel.subscribe();
```
Track only after the channel is subscribed, as above. `untrack()` removes the
current client's state.
## Status, errors and cleanup
`onStatusChange` reports the subscription status, and errors such as a join
the server declined arrive as `RealtimeException` errors on it:
```dart
final channel = supabase.channel('room-1');
channel.onStatusChange.listen(
(change) => print('Channel is ${change.status.name}'),
onError: (Object error) => print('Channel failed: $error'),
);
channel.subscribe();
await supabase.removeChannel(channel);
```
`supabase.removeAllChannels()` closes every channel, for example on sign out.
A typed `stream()` reports its failures, including a `RealtimeException`, as
errors of the stream itself.
## The app lifecycle
`Supabase` disconnects the realtime socket when the app is paused and
reconnects, rejoining every channel, when it resumes. Keep the socket open
through short pauses, such as a full-screen ad on iOS, with
`RealtimeLifecycleOptions`:
```dart
await Supabase.initialize(
url: 'https://your-project.supabase.co',
publishableKey: 'your-publishable-key',
realtimeLifecycleOptions: const RealtimeLifecycleOptions(
disconnectAfterPause: Duration(minutes: 1),
),
);
```