Building a Realtime Activity Feed with Supabase and Flutter
I added a realtime activity feed to my Flutter Web app using Supabase Realtime's .stream() API. When a user opens /activity-feed, they see live updates — new members joining, achievements unlocked, milestones hit — all pushed via WebSocket without polling.
The Stack
- Supabase Realtime — PostgreSQL CDC over WebSocket
-
Flutter —
StreamSubscription<List<Map<String, dynamic>>>listens to the stream - Fallback — HTTP query if WebSocket drops
Data Model
class ActivityItem {
final String id;
final String type; // new_user / achievement / milestone / share / level_up
final String userName;
final String action;
final DateTime timestamp;
factory ActivityItem.fromJson(Map<String, dynamic> json) {
return ActivityItem(
id: json['id'].toString(),
type: json['type'] as String? ?? 'general',
userName: json['user_name'] ?? 'anonymous',
action: json['action'] as String? ?? '',
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
}
Realtime Subscription
class _ActivityFeedPageState extends State<ActivityFeedPage> {
final _supabase = Supabase.instance.client;
StreamSubscription<List<Map<String, dynamic>>>? _activitySubscription;
void _startRealTimeSubscription() {
_activitySubscription = _supabase
.from('activities')
.stream(primaryKey: ['id'])
.order('timestamp', ascending: false)
.limit(30)
.listen(
(data) {
if (mounted) {
setState(() => _activities = data.map(ActivityItem.fromJson).toList());
}
},
onError: (_) => _loadActivities(), // fallback to HTTP on WS error
);
}
@override
void dispose() {
_activitySubscription?.cancel(); // critical — prevents memory leaks
super.dispose();
}
}
Three things to get right:
-
Pass the primary key to
.stream(primaryKey: ['id'])— enables differential updates -
onErrorfallback — gracefully handles WebSocket disconnection - Cancel on dispose — the most commonly forgotten step
HTTP Fallback
Future<void> _loadActivities() async {
final response = await _supabase
.from('activities')
.select('id, type, user_name, action, timestamp')
.order('timestamp', ascending: false)
.limit(30);
if (mounted) {
setState(() => _activities = (response as List)
.map((j) => ActivityItem.fromJson(j as Map<String, dynamic>))
.toList());
}
}
Enable Realtime on the Table
Don't forget to add the table to the Supabase Realtime publication — either from the dashboard (Database > Replication) or via migration:
ALTER PUBLICATION supabase_realtime ADD TABLE activities;
Skipping this means .stream() only returns the initial snapshot — no live updates.
.stream() vs .channel()
| API | When to use | Complexity |
|---|---|---|
.stream() |
Full table sync, latest N rows | Low |
.channel().on() |
Filtered changes (WHERE), Broadcast, Presence | Higher |
For a simple "show latest N rows live" use case, .stream() is the shortest path. Switch to .channel() when you need row-level filtering or cross-client messaging.
Result
The activity feed updates in real time with zero polling. Supabase handles the WebSocket lifecycle; Flutter's StreamSubscription maps it directly to setState. Total implementation: ~100 lines.
Building in public: https://my-web-app-b67f4.web.app/
FlutterWeb #Supabase #RealtimeDB #buildinpublic
United States
NORTH AMERICA
Related News
How Braze’s CTO is rethinking engineering for the agentic area
10h ago
Amazon Employees Are 'Tokenmaxxing' Due To Pressure To Use AI Tools
21h ago

Implementing Multicloud Data Sharding with Hexagonal Storage Adapters
15h ago

DeepMind’s CEO Says AGI May Be ~4 Years Away. The Last Three Missing Pieces Are Not What Most People Think.
15h ago

CCSnapshot - A Claude Code Configs Transfer Tool
21h ago