Realtime
Building live, collaborative features with Supabase Realtime — database subscriptions, presence, and streams.
Supabase Realtime is a feature that allows you to listen to database changes in real-time and build live, collaborative features into your Flutter apps. It's built on top of PostgreSQL and uses WebSockets for efficient communication.
✅ What You Can Build with Realtime
- Live Chat – Real-time message updates without polling
- Collaborative Editing – Multiple users editing the same document
- Live Dashboards – Real-time data visualization
- Presence Systems – See who's online and what they're doing
- Live Feeds – Social media feeds that update in real-time
- Game Leaderboards – Live score updates
Polling (Traditional)
- Repeated HTTP requests (every few seconds)
- Wastes bandwidth and battery
- High latency (up to the polling interval)
- More server load
- Simple to implement
WebSockets (Realtime)
- Persistent connection
- Efficient — only sends when data changes
- Instant updates (milliseconds)
- Less server load
- More complex setup
Realtime works through channels — logical groupings of subscriptions. You can subscribe to changes on specific tables or even specific rows.
import
'package:supabase_flutter/supabase_flutter.dart'
;
class
RealtimeService
{
final
SupabaseClient client = Supabase.instance.client;
RealtimeChannel? _channel;
// Subscribe to all changes on the 'posts' table
void
subscribeToPosts
({
void
Function(PostgresChangePayload)? onInsert,
void
Function(PostgresChangePayload)? onUpdate,
void
Function(PostgresChangePayload)? onDelete,
}) {
_channel = client.channel(
'public:posts'
)
.onPostgresChange(
PostgresChangeEvent.insert,
(payload) {
print(
'Post inserted: ${payload.newRecord}'
);
onInsert?.call(payload);
},
)
.onPostgresChange(
PostgresChangeEvent.update,
(payload) {
print(
'Post updated: ${payload.newRecord}'
);
onUpdate?.call(payload);
},
)
.onPostgresChange(
PostgresChangeEvent.delete,
(payload) {
print(
'Post deleted: ${payload.oldRecord}'
);
onDelete?.call(payload);
},
)
.subscribe();
}
// Subscribe to changes on a specific post
void
subscribeToPost
(String postId, {
void
Function(PostgresChangePayload)? onUpdate,
}) {
_channel = client.channel(
'public:posts:id=eq.$postId'
)
.onPostgresChange(
PostgresChangeEvent.update,
(payload) {
print(
'Post $postId updated: ${payload.newRecord}'
);
onUpdate?.call(payload);
},
)
.subscribe();
}
// Subscribe to comments on a specific post
void
subscribeToPostComments
(String postId, {
void
Function(PostgresChangePayload)? onInsert,
}) {
_channel = client.channel(
'public:comments:post_id=eq.$postId'
)
.onPostgresChange(
PostgresChangeEvent.insert,
(payload) {
print(
'Comment added to post $postId: ${payload.newRecord}'
);
onInsert?.call(payload);
},
)
.subscribe();
}
// Unsubscribe from all channels
void
unsubscribe
() {
_channel?.unsubscribe();
}
}
Presence allows you to track who's online and what they're doing. It's perfect for chat apps, collaborative tools, and social features.
import
'package:supabase_flutter/supabase_flutter.dart'
;
class
PresenceService
{
final
SupabaseClient client = Supabase.instance.client;
RealtimeChannel? _presenceChannel;
final
Map<String, dynamic> _onlineUsers = {};
Map
<String, dynamic>
get
onlineUsers => _onlineUsers;
void
initializePresence
(String roomId, String userId, String username) {
_presenceChannel = client.channel(
'presence:$roomId'
,
opts: const RealtimeChannelOptions(
config: PresenceConfig(
key:
'user_id'
,
),
),
);
// Track when users join
_presenceChannel!.onPresenceSync((payload) {
final
presenceState = payload.presenceState;
_onlineUsers.clear();
presenceState.forEach((key, value) {
final
user = value.first
as
Map<String, dynamic>;
_onlineUsers[key] = user;
});
});
// Track when users leave
_presenceChannel!.onPresenceDiff((diff) {
diff.leaves.forEach((key, value) {
_onlineUsers.remove(key);
});
});
// Join the presence channel
_presenceChannel!.subscribe((status) async {
if
(status ==
'SUBSCRIBED'
) {
final
presenceData = {
'user_id'
: userId,
'username'
: username,
'online_at'
: DateTime.now().toIso8601String(),
};
await
_presenceChannel!.track(presenceData);
}
});
}
void
updatePresence
({
required
String status,
required
String userId,
}) {
final
presenceData = {
'user_id'
: userId,
'status'
: status,
'last_active'
: DateTime.now().toIso8601String(),
};
_presenceChannel?.track(presenceData);
}
void
dispose
() {
_presenceChannel?.unsubscribe();
}
bool
isUserOnline
(String userId) {
return
_onlineUsers.containsKey(userId);
}
int
getOnlineCount
() {
return
_onlineUsers.length;
}
}
class
OnlineUsersWidget
extends
StatefulWidget {
final
PresenceService presenceService;
const
OnlineUsersWidget({
required
this
.presenceService, super.key});
@override
State<OnlineUsersWidget>
createState
() => _OnlineUsersWidgetState();
}
class
_OnlineUsersWidgetState
extends
State<OnlineUsersWidget> {
late
PresenceService _presenceService;
@override
void
initState
() {
super
.initState();
_presenceService = widget.presenceService;
}
@override
Widget
build
(BuildContext context) {
final
users = _presenceService.onlineUsers;
final
count = _presenceService.getOnlineCount();
return
Container(
padding: EdgeInsets.all(
16
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'🟢 $count online'
,
style: TextStyle(fontWeight: FontWeight.bold),
),
SizedBox(height:
8
),
Wrap(
spacing:
8
,
runSpacing:
8
,
children: users.entries.map((entry) {
final
user = entry.value
as
Map<String, dynamic>;
return
Chip(
avatar: CircleAvatar(
radius:
12
,
child: Text(
(user[
'username'
] ??
'?'
)[
0
].toUpperCase(),
style: TextStyle(fontSize:
10
),
),
),
label: Text(user[
'username'
] ??
'Unknown'
),
backgroundColor: Colors.green.shade50,
);
}).toList(),
),
],
),
);
}
}
You can convert Realtime subscriptions into Dart Streams for easy integration with Flutter's reactive programming model.
import
'dart:async'
;
import
'package:supabase_flutter/supabase_flutter.dart'
;
class
StreamService
{
final
SupabaseClient client = Supabase.instance.client;
RealtimeChannel? _channel;
final
StreamController<List<Map<String, dynamic>>> _messagesController =
StreamController<List<Map<String, dynamic>>>.broadcast();
Stream<List<Map<String, dynamic>>>
get
messagesStream => _messagesController.stream;
void
subscribeToMessages
(String roomId) {
_channel = client.channel(
'public:messages:room_id=eq.$roomId'
)
.onPostgresChange(
PostgresChangeEvent.insert,
(payload) {
// Get current messages from the database
_fetchCurrentMessages(roomId);
},
)
.onPostgresChange(
PostgresChangeEvent.update,
(payload) {
_fetchCurrentMessages(roomId);
},
)
.onPostgresChange(
PostgresChangeEvent.delete,
(payload) {
_fetchCurrentMessages(roomId);
},
)
.subscribe();
// Initial fetch
_fetchCurrentMessages(roomId);
}
Future
<
void
>
_fetchCurrentMessages
(String roomId) async {
try
{
final
response =
await
client
.from(
'messages'
)
.select(
'*, profiles(username)'
)
.eq(
'room_id'
, roomId)
.order(
'created_at'
, ascending:
true
);
_messagesController.add(response);
}
catch
(e) {
print(
'Error fetching messages: $e'
);
}
}
void
dispose
() {
_channel?.unsubscribe();
_messagesController.close();
}
}
// Using the stream with Riverpod
final
messagesStreamProvider = StreamProvider<List<Map<String, dynamic>>>((ref) {
final
service = ref.watch(streamServiceProvider);
return
service.messagesStream;
});
Here's a complete live chat app using Supabase Realtime for instant message updates.
import
'package:flutter/material.dart'
;
import
'package:supabase_flutter/supabase_flutter.dart'
;
import
'package:intl/intl.dart'
;
void
main
() async {
await
Supabase.initialize(
url:
'https://your-project.supabase.co'
,
anonKey:
'your-anon-key'
,
);
runApp(
MyApp
());
}
class
MyApp
extends
StatelessWidget {
@override
Widget
build
(BuildContext context) {
return
MaterialApp(
title:
'Live Chat'
,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3:
true
,
),
home: ChatRoomScreen(),
);
}
}
// ==========================================
// 1. CHAT SERVICE
// ==========================================
class
ChatService
{
final
SupabaseClient client = Supabase.instance.client;
RealtimeChannel? _channel;
final
List<Map<String, dynamic>> _messages = [];
final
List<Function> _listeners = [];
List
<Map<String, dynamic>>
get
messages => _messages;
void
addListener
(Function listener) {
_listeners.add(listener);
}
void
removeListener
(Function listener) {
_listeners.remove(listener);
}
void
_notifyListeners
() {
for
(
var
listener
in
_listeners) {
listener();
}
}
Future
<
void
>
connect
(String roomId) async {
// Load initial messages
await
_loadMessages(roomId);
// Subscribe to real-time updates
_channel = client.channel(
'public:messages:room_id=eq.$roomId'
)
.onPostgresChange(
PostgresChangeEvent.insert,
(payload) async {
// Check if the new message belongs to this room
final
newRecord = payload.newRecord
as
Map<String, dynamic>;
if
(newRecord[
'room_id'
] == roomId) {
// Fetch the full message with user profile
final
fullMessage =
await
client
.from(
'messages'
)
.select(
'*, profiles(username)'
)
.eq(
'id'
, newRecord[
'id'
])
.single();
_messages.add(fullMessage);
_notifyListeners();
}
},
)
.subscribe();
}
Future
<
void
>
_loadMessages
(String roomId) async {
final
response =
await
client
.from(
'messages'
)
.select(
'*, profiles(username)'
)
.eq(
'room_id'
, roomId)
.order(
'created_at'
, ascending:
true
);
_messages.clear();
_messages.addAll(response);
_notifyListeners();
}
Future
<
void
>
sendMessage
({
required
String roomId,
required
String userId,
required
String content,
}) async {
if
(content.trim().isEmpty)
return
;
await
client.from(
'messages'
).insert({
'room_id'
: roomId,
'user_id'
: userId,
'content'
: content.trim(),
});
}
void
disconnect
() {
_channel?.unsubscribe();
_listeners.clear();
}
}
// ==========================================
// 2. CHAT UI
// ==========================================
class
ChatRoomScreen
extends
StatefulWidget {
@override
State<ChatRoomScreen>
createState
() => _ChatRoomScreenState();
}
class
_ChatRoomScreenState
extends
State<ChatRoomScreen> {
final
ChatService _chatService = ChatService();
final
TextEditingController _messageController = TextEditingController();
final
ScrollController _scrollController = ScrollController();
String _roomId =
'general'
;
@override
void
initState
() {
super
.initState();
_chatService.addListener(_updateUI);
_connectToChat();
}
@override
void
dispose
() {
_chatService.removeListener(_updateUI);
_chatService.disconnect();
_messageController.dispose();
_scrollController.dispose();
super
.dispose();
}
void
_updateUI
() {
setState(() {});
_scrollToBottom();
}
void
_scrollToBottom
() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if
(_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: Duration(milliseconds:
300
),
curve: Curves.easeOut,
);
}
});
}
Future
<
void
>
_connectToChat
() async {
await
_chatService.connect(_roomId);
setState(() {});
_scrollToBottom();
}
Future
<
void
>
_sendMessage
() async {
final
user = Supabase.instance.client.auth.currentUser;
if
(user ==
null
)
return
;
await
_chatService.sendMessage(
roomId: _roomId,
userId: user.id,
content: _messageController.text,
);
_messageController.clear();
}
@override
Widget
build
(BuildContext context) {
final
messages = _chatService.messages;
final
user = Supabase.instance.client.auth.currentUser;
return
Scaffold(
appBar: AppBar(
title: Row(
children: [
Container(
width:
10
,
height:
10
,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.green,
),
),
SizedBox(width:
8
),
Text(
'#$_roomId'
),
],
),
centerTitle:
true
,
),
body: Column(
children: [
Expanded(
child: messages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size:
80
, color: Colors.grey.shade400),
SizedBox(height:
16
),
Text(
'No messages yet'
,
style: TextStyle(fontSize:
20
, fontWeight: FontWeight.w600),
),
Text(
'Be the first to send a message!'
,
style: TextStyle(color: Colors.grey.shade400),
),
],
),
)
: ListView.builder(
controller: _scrollController,
padding: EdgeInsets.all(
12
),
itemCount: messages.length,
itemBuilder: (context, index) {
final
message = messages[index];
final
profile = message[
'profiles'
]
as
Map<String, dynamic>?;
final
username = profile?[
'username'
] ??
'Unknown'
;
final
isMe = message[
'user_id'
] == user?.id;
final
createdAt = DateTime.parse(message[
'created_at'
]);
return
Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: EdgeInsets.only(bottom:
8
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width *
0.75
,
),
child: Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if
(!isMe)
CircleAvatar(
radius:
12
,
child: Text(
username.isNotEmpty ? username[
0
].toUpperCase() :
'?'
,
style: TextStyle(fontSize:
10
),
),
),
SizedBox(width:
4
),
Text(
isMe ?
'You'
: username,
style: TextStyle(
fontSize:
12
,
fontWeight: FontWeight.w600,
color: isMe ? Colors.deepPurple : Colors.grey.shade700,
),
),
],
),
Container(
padding: EdgeInsets.all(
12
),
decoration: BoxDecoration(
color: isMe
? Colors.deepPurple.shade50
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(
12
),
),
child: Text(message[
'content'
]),
),
Text(
DateFormat('h:mm a').format(createdAt),
style: TextStyle(
fontSize:
10
,
color: Colors.grey.shade500,
),
),
],
),
),
);
},
),
),
// Input bar
Container(
padding: EdgeInsets.all(
12
),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText:
'Type a message...'
,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
24
),
),
contentPadding: EdgeInsets.symmetric(horizontal:
16
, vertical:
8
),
),
onSubmitted: (_) => _sendMessage(),
),
),
SizedBox(width:
8
),
IconButton(
icon: Icon(Icons.send, color: Colors.deepPurple),
onPressed: _sendMessage,
),
],
),
),
],
),
);
}
}
client.channel('public:table_name')
to create a Realtime channel.
.onPostgresChange()
to listen to INSERT, UPDATE, DELETE events.
.subscribe()
to start receiving real-time updates.
✅ Do's
- Always unsubscribe from channels when leaving a screen
- Use filters to only listen to relevant data (e.g., specific room)
- Handle reconnection logic for network interruptions
- Use StreamBuilder or ValueListenableBuilder for reactive UI
- Limit the number of channels to avoid performance issues
❌ Don'ts
- Don't forget to dispose streams and channels
- Don't listen to all tables — only subscribe to what you need
- Don't rely on Realtime for critical data consistency (use transactions)
Realtime must be enabled for each table in the Supabase Dashboard. Without this, subscriptions won't work.
Go to Database → Replication and enable Realtime for the tables you need.
Leaving channels open can cause memory leaks and unnecessary network usage.
Call
channel.unsubscribe()
in
dispose()
or when navigating away.
Network issues can cause Realtime connections to drop. Handle reconnection gracefully.
Use the
onReconnection
callback or listen to the connection status.
🎯 Key Takeaway
Supabase Realtime enables live, collaborative features in your Flutter apps. Use channels to subscribe to database changes, presence to track online users, and streams for reactive programming. Always enable Realtime in the dashboard and unsubscribe from channels when done.