Smart Polling in Flutter: Build It Once, Run It Right

Polling is underrated. Whether you're refreshing stock prices, checking game state, or syncing with a backend - it's everywhere. But most polling implementations are dumb: they keep firing even when the widget is dead, the screen is inactive, or the request is already taking forever.
So I wrote a smarter, cleaner solution: PollingBase<T>
.
Let's break it down.
What is PollingBase<T>
?
It's a Dart class that does periodic polling. But with guardrails.
Features:
- Accepts a
Future<T> Function()
as the request function - Polls at a defined interval
- Optional debounce (to wait a bit before firing)
- Optional timeout (auto-cancel long requests)
- Skips polling if previous request isn't done
- Only runs when your widget is mounted and screen is active
- Manual
start()
andstop()
control
The Problem with Naive Polling
Timer.periodic(Duration(seconds: 5), (_) => fetchData());
Sure, that works. Until:
- The widget unmounts, but polling continues
- User switches tabs, but polling burns battery
- The server hangs and you stack up requests
- You want to debounce... good luck layering that in
The Smart Way
Here's PollingBase<T>
:
class PollingBase<T> {
final Future<T> Function() request;
final Duration interval;
final Duration? debounceDuration;
final Duration? timeoutDuration;
final bool Function() shouldPoll;
final bool Function() isScreenActive;
Timer? _intervalTimer;
Timer? _debounceTimer;
bool _isRunning = false;
bool _isRequestInProgress = false;
PollingBase({
required this.request,
required this.interval,
this.debounceDuration,
this.timeoutDuration,
required this.shouldPoll,
required this.isScreenActive,
});
void start() {
if (_isRunning) return;
_isRunning = true;
_poll(); // Run immediately
_intervalTimer = Timer.periodic(interval, (_) => _poll());
}
void stop() {
_isRunning = false;
_intervalTimer?.cancel();
_debounceTimer?.cancel();
}
void _poll() {
if (!_isRunning || !_canPoll() || _isRequestInProgress) return;
if (debounceDuration != null) {
_debounceTimer?.cancel();
_debounceTimer = Timer(debounceDuration!, _executeRequest);
} else {
_executeRequest();
}
}
bool _canPoll() {
return shouldPoll() && isScreenActive();
}
void _executeRequest() async {
if (_isRequestInProgress) return;
_isRequestInProgress = true;
try {
final future = request();
if (timeoutDuration != null) {
await future.timeout(timeoutDuration!);
} else {
await future;
}
} catch (_) {
// Silent fail - can extend with logging
} finally {
_isRequestInProgress = false;
}
}
}
How to Use It
Inside a widget:
late PollingBase<String> polling;
@override
void initState() {
super.initState();
polling = PollingBase<String>(
request: fetchData,
interval: Duration(seconds: 10),
debounceDuration: Duration(milliseconds: 500),
timeoutDuration: Duration(seconds: 4),
shouldPoll: () => mounted,
isScreenActive: () => ModalRoute.of(context)?.isCurrent ?? false,
);
polling.start();
}
@override
void dispose() {
polling.stop();
super.dispose();
}
Bonus: Works Outside Widgets Too
Because it doesn't depend on State
, PollingBase<T>
can be used in services, BLoC, or anywhere else.
Why This Matters
Polling isn't glamorous, but it powers real-time UX. The difference between "damn this feels fast" and "is this even working?" is sometimes 500 ms of smart polling logic.
This is a drop-in utility to make that logic airtight - without turning your widget into a timer spaghetti monster.
If you found this helpful, feel free to steal, fork, or improve it. This is how we build better systems: one line of clean logic at a time.