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

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() and stop() 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.