Supabase Realtime + Flutter: Live UI Without Polling
Supabase Realtime pushes database changes via WebSocket. No polling, no repeated API calls, sub-100ms updates. Here's how this project uses it.
Polling vs Realtime
Polling (every 5 seconds):
Flutter → GET /api/data → Supabase → response → display
Problem: 5-second delay, constant API consumption
Realtime (WebSocket):
Flutter ←── WebSocket ──← Supabase (pushes changes immediately)
Result: sub-100ms updates, zero client-side requests
Basic Setup: Listen to Table Changes
// lib/services/realtime_service.dart
class RealtimeService {
final SupabaseClient _supabase = Supabase.instance.client;
RealtimeChannel? _channel;
void subscribeToNotes({required void Function(List<Map<String, dynamic>>) onUpdate}) {
_channel = _supabase
.channel('public:notes')
.onPostgresChanges(
event: PostgresChangeEvent.all, // INSERT / UPDATE / DELETE
schema: 'public',
table: 'notes',
callback: (payload) {
onUpdate(payload.newRecord.isEmpty ? [] : [payload.newRecord]);
},
)
.subscribe();
}
void unsubscribe() {
_channel?.unsubscribe();
}
}
Integration with Flutter Widget
class _NotesRealtimePageState extends State<NotesRealtimePage> {
final _service = RealtimeService();
List<Map<String, dynamic>> _notes = [];
@override
void initState() {
super.initState();
_loadInitialData();
_service.subscribeToNotes(
onUpdate: (records) {
if (mounted) {
setState(() {
for (final record in records) {
final index = _notes.indexWhere((n) => n['id'] == record['id']);
if (index >= 0) {
_notes[index] = record; // update existing
} else {
_notes.insert(0, record); // prepend new
}
}
});
}
},
);
}
Future<void> _loadInitialData() async {
final data = await Supabase.instance.client
.from('notes')
.select()
.order('created_at', ascending: false)
.limit(50);
setState(() => _notes = List<Map<String, dynamic>>.from(data));
}
@override
void dispose() {
_service.unsubscribe();
super.dispose();
}
}
Load initial data in initState, subscribe to Realtime, unsubscribe in dispose.
RLS + Realtime
RLS policies apply to Realtime automatically:
-- This policy means Realtime also delivers only the user's own rows
CREATE POLICY "users_select_own" ON notes
FOR SELECT USING (auth.uid() = user_id);
Use an authenticated client (with the user's JWT) and Realtime respects RLS automatically. No extra work.
Broadcast: Client-to-Client Messaging
Send arbitrary messages between clients without writing to the database:
// Sender
await _supabase
.channel('room:${roomId}')
.sendBroadcastMessage(
event: 'cursor_move',
payload: {'x': 0.5, 'y': 0.3, 'userId': userId},
);
// Receiver
_supabase
.channel('room:${roomId}')
.onBroadcast(
event: 'cursor_move',
callback: (payload) {
setState(() => _cursors[payload['userId']] = Offset(
payload['x'] as double,
payload['y'] as double,
));
},
)
.subscribe();
Presence: Online User Tracking
final channel = _supabase.channel('online_users');
channel
.onPresenceSync(callback: (_) {
final state = channel.presenceState();
setState(() => _onlineCount = state.length);
})
.subscribe((_,__) async {
await channel.track({
'user_id': userId,
'online_at': DateTime.now().toIso8601String(),
});
});
track() shares presence info. presenceState() returns everyone currently online.
Production Notes
1. Channel name scope.
public:notes receives changes from all users. Either rely on RLS to filter, or use scoped channel names like user:${userId}:notes.
2. Connection management.
The WebSocket connection is shared app-wide. Don't subscribe to the same channel multiple times. Always unsubscribe in dispose.
3. Enable Realtime per table.
Tables need Realtime enabled in the Supabase dashboard, or via migration:
ALTER PUBLICATION supabase_realtime ADD TABLE notes;
Summary
Supabase Realtime + Flutter in practice:
-
onPostgresChanges— listen to table changes - RLS applies to Realtime — no separate auth logic
-
Broadcast— client-to-client events without DB writes -
Presence— who's online right now - Always unsubscribe in
dispose
Drop polling. Realtime makes both the code and the UX simpler.
Top comments (0)