Smart Polling in Flutter: Build It Once, Run It Right
Most polling in Flutter is wasteful. This post shares a production-ready Dart class that handles lifecycle, timeouts, and smart intervals no spaghetti timers.
Polling is underrated. Whether you're refreshing stock prices, checking game state, or syncing with a backend, it is everywhere.
But most polling implementations are dumb. They keep firing even when the widget is dead, the screen is inactive, or the previous request is still hanging.
I got tired of writing boilerplate Timer logic in every widget, so I wrote a smarter, cleaner solution: PollingBase<T>.
Let’s break down why the standard way fails and how to fix it.
The Problem with Timer.periodic
We’ve all written this code:
// The Naive Approach
@override
void initState() {
super.initState();
Timer.periodic(Duration(seconds: 5), (_) async {
final data = await api.fetchData();
setState(() => _data = data);
});
}Sure, that works. Until:
- Zombie Timers: The widget unmounts, but the timer keeps running, causing memory leaks and setState errors.
- Battery Burn: The user switches tabs or turns off their screen, but your app keeps hitting the network every 5 seconds.
- Request Pile-up: The server hangs for 6 seconds, but your timer fires every 5. Now you have overlapping requests processing simultaneously.
You can fix this with cancel() logic, WidgetsBindingObserver, and boolean flags, but your widget code quickly turns into spaghetti.
The Smart Way: PollingBase<T>
We need a class that handles the periodic logic but respects Guardrails.
The Features:
- Reactive: Exposes a Stream<T> for easy use with StreamBuilder.
- Concurrency Safe: Skips the next poll if the previous request isn't done yet.
- Lifecycle Aware: Only polls when your widget is mounted and the screen is active.
- Timeout Handling: Auto-cancels requests that take too long.
The Code
Here is the complete, drop-in utility class:
import 'dart:async';
class PollingBase<T> {
final Future<T> Function() request;
final Duration interval;
final Duration? timeoutDuration;
// Guardrails: Return false to skip a poll cycle
final bool Function()? shouldPoll;
final bool Function()? isScreenActive;
Timer? _intervalTimer;
bool _isRunning = false;
bool _isRequestInProgress = false;
// We use a Stream so your UI can react naturally
final StreamController<T> _controller = StreamController<T>.broadcast();
Stream<T> get stream => _controller.stream;
PollingBase({
required this.request,
required this.interval,
this.timeoutDuration,
this.shouldPoll,
this.isScreenActive,
});
void start() {
if (_isRunning) return;
_isRunning = true;
_poll(); // Run immediately on start
_intervalTimer = Timer.periodic(interval, (_) => _poll());
}
void stop() {
_isRunning = false;
_intervalTimer?.cancel();
_intervalTimer = null;
}
// Force a refresh (bypasses interval, respects in-progress flag)
void refresh() => _poll();
void dispose() {
stop();
_controller.close();
}
void _poll() {
// 1. Check strict constraints
if (!_isRunning && _intervalTimer != null) return;
if (_isRequestInProgress) return;
// 2. Check User Guardrails
final canPoll = (shouldPoll?.call() ?? true) &&
(isScreenActive?.call() ?? true);
if (!canPoll) return;
_executeRequest();
}
Future<void> _executeRequest() async {
_isRequestInProgress = true;
try {
Future<T> future = request();
if (timeoutDuration != null) {
future = future.timeout(timeoutDuration!);
}
final result = await future;
// Only add data if the poller is still running
if (!_controller.isClosed && _isRunning) {
_controller.add(result);
}
} catch (e, stackTrace) {
if (!_controller.isClosed && _isRunning) {
_controller.addError(e, stackTrace);
}
} finally {
_isRequestInProgress = false;
}
}
}How to Use It
Because PollingBase exposes a stream, using it in a widget is incredibly clean. You don't need setState; you just need a StreamBuilder.
class CryptoTicker extends StatefulWidget {
@override
_CryptoTickerState createState() => _CryptoTickerState();
}
class _CryptoTickerState extends State<CryptoTicker> {
late PollingBase<String> _poller;
@override
void initState() {
super.initState();
_poller = PollingBase<String>(
request: fetchBitcoinPrice,
interval: Duration(seconds: 5),
timeoutDuration: Duration(seconds: 3),
// GUARDRAIL 1: Stop polling if widget is unmounted
shouldPoll: () => mounted,
// GUARDRAIL 2: Stop polling if user navigates to another screen
isScreenActive: () => ModalRoute.of(context)?.isCurrent ?? true,
);
_poller.start();
}
@override
void dispose() {
_poller.dispose(); // Cleans up Timer and Stream
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: StreamBuilder<String>(
stream: _poller.stream,
builder: (context, snapshot) {
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
if (!snapshot.hasData) return CircularProgressIndicator();
return Text("BTC: ${snapshot.data}");
},
),
),
// Bonus: Easy "Pull to Refresh" logic
floatingActionButton: FloatingActionButton(
onPressed: _poller.refresh,
child: Icon(Icons.refresh),
),
);
}
Future<String> fetchBitcoinPrice() async {
// Your API logic here
await Future.delayed(Duration(milliseconds: 500));
return "\$95,000";
}
}Why This Matters
Polling isn't glamorous, but it powers real-time UX. The difference between "damn this feels fast" and "is this thing working?" is often just 500ms of smart logic.
By moving the complexity into PollingBase, your widgets stay dumb (which is good) and your logic stays airtight. You prevent memory leaks, save user battery, and handle network flakiness, all without spaghetti code.
Copy-paste PollingBase into your utils folder and thank me later.