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.