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.

Smart Polling in Flutter

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.

Mastodon