Real-Time Features in Flutter: Choosing Between Polling, WebSockets, SSE and Listeners
How I build real-time features in Flutter: polling vs WebSockets vs SSE vs Firestore listeners vs SignalR, plus reconnects, app lifecycle, ordering and battery.

“Can it update live?” is one of the easiest feature requests to say yes to and one of the hardest to get right. Opening a socket takes five minutes. Keeping it healthy while the phone switches from Wi-Fi to cellular, goes into a pocket, and comes back an hour later is the actual job.
I’ve built real-time sync on Range Buddy with Firebase, and live location tracking with Google Maps as lead Flutter developer at Mascopia. Different stacks, same lessons. Here’s how I pick a transport and what I build around it.
First question: how live is “live”?
Before picking technology, I ask what happens if an update arrives five seconds late. Or thirty. The answer changes everything.
- A chat message five seconds late feels broken.
- A driver’s position on a map five seconds late is fine, as long as the marker animates smoothly.
- An order status thirty seconds late is usually fine, and a push notification might be the better tool anyway.
Plenty of “real-time” requests turn out to be “fresh when I look at it.” That’s a cache refresh on screen open plus pull-to-refresh, not a persistent connection.
The options
Polling
Call an endpoint every N seconds. It’s unfashionable and often correct. It works through every proxy and firewall, it’s trivial to debug, it needs no special server infrastructure, and it stops naturally when you stop the timer.
The cost is latency (up to one interval) and wasted requests when nothing changed. For dashboards and status screens that a user opens occasionally, I’ll take that trade. Use a conditional request (ETag or a “since” timestamp) so unchanged polls are cheap.
WebSockets
A persistent, two-way connection. The right choice when updates are frequent, latency matters, and the client also sends messages: chat, collaborative editing, live bidding. In Flutter, web_socket_channel is the standard package:
final channel = WebSocketChannel.connect(
Uri.parse('wss://api.example.com/live'),
);
await channel.ready; // throws if the connection fails
channel.stream.listen(
(message) => handleMessage(message),
onDone: () => scheduleReconnect(),
onError: (_) => scheduleReconnect(),
);
channel.sink.add(jsonEncode({'type': 'subscribe', 'room': roomId}));
You own everything around that: auth on connect, heartbeats, reconnects, resubscribing. More on those below.
Server-Sent Events
A one-way stream from server to client over plain HTTP. If the client only listens (a live feed, progress updates, notifications in an open screen), SSE is simpler than WebSockets on the server and plays nicer with HTTP infrastructure. Flutter has no built-in SSE client, but the protocol is simple text over a streamed HTTP response, and there are packages for it. I reach for it less often on mobile, mostly because the backends I work with already offer something better suited.
Firestore listeners
If you’re on Firebase, snapshots() gives you real-time updates with reconnection, offline caching and local-write latency compensation handled for you. That’s a lot of hard work you don’t do. It’s what I used on Range Buddy, and for that kind of app it’s a strong default.
FirebaseFirestore.instance
.collection('sessions')
.doc(sessionId)
.snapshots()
.listen((snapshot) => updateSession(snapshot.data()));
The catches: you pay per document read, so a listener on a large, chatty collection gets expensive. And your security rules become your API, so they need the same care as backend code. I compare it with Supabase’s realtime in Firebase vs Supabase for Flutter apps.
SignalR
If your backend is .NET, as many of mine are, SignalR is the natural fit. It handles transport negotiation (WebSockets first, with fallbacks), groups, and per-user messaging, and it plugs straight into ASP.NET Core authentication. On the Flutter side there are community client packages that speak the SignalR protocol. They’re not maintained by Microsoft, so I pin versions and test reconnect behavior carefully before trusting one. On Azure, the managed SignalR Service takes connection scaling off your API servers.
Reconnects with backoff
Every persistent connection will drop. The question is what you do next. Reconnecting instantly in a tight loop is the classic mistake: if the server is down, every client hammers it the moment it comes back.
Use exponential backoff with jitter:
class Backoff {
Backoff({
this.base = const Duration(seconds: 1),
this.max = const Duration(seconds: 30),
});
final Duration base;
final Duration max;
final _random = Random();
int _attempt = 0;
Duration next() {
final exp = base.inMilliseconds * pow(2, _attempt++);
final capped = min(exp, max.inMilliseconds).toInt();
// Full jitter: a random delay between 0 and the capped value.
return Duration(milliseconds: _random.nextInt(capped + 1));
}
void reset() => _attempt = 0;
}
Call reset() once a connection has been stable for a little while, not the moment it opens. Otherwise a server that accepts connections and immediately drops them puts you right back into a tight loop.
Two more things I always add:
- Heartbeats. A connection can look open while being dead, especially across network changes. Send a ping every so often and treat a missed pong as a disconnect.
- Resync after reconnect. Messages sent while you were disconnected are gone. After reconnecting, fetch what you missed (by last-seen ID or timestamp) before trusting the live stream again.
Respect the app lifecycle
A socket held open while the app is in the background drains battery and, on iOS, gets suspended by the system anyway. I close connections when the app goes to the background and reopen them on return. AppLifecycleListener makes this tidy:
late final AppLifecycleListener _lifecycle;
@override
void initState() {
super.initState();
_lifecycle = AppLifecycleListener(
onResume: () => liveService.connect(),
onPause: () => liveService.disconnect(),
);
}
@override
void dispose() {
_lifecycle.dispose();
super.dispose();
}
On resume, that resync step above matters most. The user just came back and expects to see what happened while they were away.
If something must reach the user while the app is closed, that’s a job for push notifications, not a socket. See push notifications in Flutter with FCM.
Ordering and deduplication
Networks reorder and repeat. Reconnect logic plus resync means you will sometimes get the same message twice: once from the resync fetch, once from the live stream. Design for it:
- Give every event a unique ID and keep a small set of recently seen IDs. Drop duplicates.
- Give events a sequence number or server timestamp, and apply updates only if they’re newer than what you have. For a location marker, an older position arriving late should be ignored, not drawn.
- Make updates idempotent. “Set status to delivered” is safe to apply twice. “Increment unread count” is not. Prefer sending state over sending deltas when you can.
Keep the live stream out of your widgets. A repository or service owns the connection and writes into the same local state the rest of the app reads. That way the UI doesn’t care whether data came from a socket, a resync, or the cache, which is the same principle behind offline-first Flutter apps.
Presence
“Who’s online” sounds simple and isn’t. The server only knows a client is gone when the connection drops or a heartbeat times out, and mobile connections drop all the time without the user leaving.
What works: mark users online on connect, mark them offline after a grace period with no connection, and show “last seen” rather than a hard online/offline flag. Firebase’s Realtime Database has an onDisconnect hook built for exactly this. With SignalR or raw WebSockets, you track it on the server yourself.
Battery and data
Real-time features are some of the easiest ways to make an app a battery hog. My rules:
- Only connect while a screen that needs live data is visible, not for the whole app session.
- Batch or throttle high-frequency updates. A map doesn’t need twenty position updates a second. Smooth the marker between less frequent updates instead.
- Unsubscribe from what’s off screen. A Firestore listener on a tab the user left five minutes ago is still costing reads.
- On the sending side (like live location), match update frequency to what the feature needs. Accuracy and interval settings have a large effect on battery.
Takeaways
- Decide how live “live” really is before choosing a transport. Polling is often enough.
- Firestore listeners and SignalR handle a lot of reconnection work for you. Raw WebSockets leave it all to you.
- Reconnect with exponential backoff and jitter, add heartbeats, and resync missed data after every reconnect.
- Disconnect in the background and reconnect on resume with
AppLifecycleListener. - Expect duplicates and out-of-order events. Use IDs, sequence numbers and idempotent updates.
- Keep the connection in a service layer and connect only while live data is on screen.
If you’re planning a live feature and want to get the transport choice right the first time, let’s talk.
Comments
Questions, corrections or your own experience — leave a comment below (GitHub sign-in).