Flutter Connectivity Done Right: Reliable Network Status and Online Detection

Learn reliable ways to check network status in Flutter, from connectivity_plus to true internet reachability, with production-ready code and UX tips.

ASOasis
8 min read
Flutter Connectivity Done Right: Reliable Network Status and Online Detection

Image used for representation purposes only.

Overview

Reliable network status detection is essential for any modern Flutter app. Users expect graceful offline behavior, quick recovery once a connection returns, and clear feedback when their network is blocked or flaky. In this guide you’ll learn how to:

  • Differentiate “connectivity” from true “internet reachability.”
  • Build a robust NetworkStatus service that merges both signals.
  • Handle platform differences (mobile vs. web/desktop).
  • Present a clean offline UX and avoid battery-hungry polling.
  • Test your logic predictably.

All examples use null safety and modern Flutter/Dart patterns.

Connectivity vs. Internet Reachability

Flutter’s go-to package for link-layer connectivity is connectivity_plus. It reports whether a device is on Wi‑Fi, mobile, ethernet, or has no interface at all. However, having a network interface does not guarantee that the internet is reachable. Common reasons include:

  • Captive portals (hotel/airport Wi‑Fi requiring a sign-in page)
  • DNS issues or blocked ports
  • Local network with no upstream internet
  • System firewall or VPN rules

Therefore, you need two signals:

  1. Connectivity state (Wi‑Fi, mobile, ethernet, none)
  2. Internet reachability (can we actually contact a host?)

Combining both yields a reliable experience.

Package Setup

Add the following to your pubspec.yaml and run flutter pub get.

dependencies:
  flutter:
    sdk: flutter
  connectivity_plus: ^
  http: ^
  • connectivity_plus detects network interface changes.
  • http provides a cross-platform way to attempt a lightweight request (especially useful for web).

If you target only mobile/desktop, you can also verify reachability using sockets from dart:io. For web builds, use HTTP because raw sockets are not available in browsers.

Designing a Robust Model

Let’s define two enums and a state object:

enum ConnectionType { wifi, mobile, ethernet, none }

enum OnlineStatus { online, offline }

class NetworkState {
  final ConnectionType type;
  final OnlineStatus online;
  final DateTime checkedAt;

  const NetworkState({
    required this.type,
    required this.online,
    required this.checkedAt,
  });

  bool get isOnline => online == OnlineStatus.online;
}

NetworkState captures both the transport type and true online status, timestamped for debugging and telemetry.

Implementing Internet Reachability

There are two main approaches. You can support both with a platform switch.

  1. Socket check (mobile/desktop): Try to open a short-lived TCP socket to a well-known DNS server (port 53). Success implies basic internet path availability without downloading content.

  2. HTTP HEAD/GET (web and fallback): Perform a tiny fetch to a fast endpoint that returns 204/200 with minimal body. Use a strict timeout, and avoid privacy-sensitive or geo-blocked URLs by making the endpoint configurable.

import 'dart:async';
import 'dart:io' show Socket; // Not available on web
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http;

class Reachability {
  static const Duration timeout = Duration(seconds: 2);

  // Configurable endpoints to increase resilience.
  static const List<(String host, int port)> _socketTargets = [
    ('1.1.1.1', 53), // Cloudflare DNS
    ('8.8.8.8', 53), // Google DNS
  ];

  static const List<Uri> _httpTargets = [
    // Choose a lightweight, reliable URL. Replace with your own if needed.
    Uri.parse('https://www.google.com/generate_204'),
    Uri.parse('https://httpstat.us/204'),
    Uri.parse('https://example.com/'),
  ];

  static Future<bool> hasInternet() async {
    if (kIsWeb) {
      return _checkViaHttp();
    } else {
      // Try sockets first (fast, low-data), then fallback to HTTP.
      final s = await _checkViaSocket();
      return s ?? await _checkViaHttp();
    }
  }

  static Future<bool?> _checkViaSocket() async {
    for (final target in _socketTargets) {
      try {
        final socket = await Socket.connect(target.$1, target.$2, timeout: timeout);
        socket.destroy();
        return true;
      } catch (_) {
        // try next target
      }
    }
    return null; // inconclusive; fall back to HTTP
  }

  static Future<bool> _checkViaHttp() async {
    for (final uri in _httpTargets) {
      try {
        final res = await http.head(uri).timeout(timeout);
        if (res.statusCode >= 200 && res.statusCode < 400) return true;
      } catch (_) {
        // try next target
      }
    }
    return false;
  }
}

Tips:

  • Make endpoints configurable and testable.
  • Use short timeouts and avoid tight loops to conserve battery.
  • Consider privacy: selecting a neutral domain you control is ideal for enterprise apps.

Building a NetworkStatus Service

Now we’ll merge connectivity changes with reachability checks into a debounced stream your UI can listen to.

import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';

class NetworkStatusService {
  final Connectivity _connectivity;
  final _controller = StreamController<NetworkState>.broadcast();
  StreamSubscription<List<ConnectivityResult>>? _subscription;

  // Debounce rapid changes
  Timer? _debounce;
  Duration debounceDuration;

  NetworkState? _last;

  NetworkStatusService({Connectivity? connectivity, this.debounceDuration = const Duration(milliseconds: 300)})
      : _connectivity = connectivity ?? Connectivity();

  Stream<NetworkState> get onStatusChange => _controller.stream;

  Future<void> start() async {
    // Emit initial state
    await _emit();

    _subscription = _connectivity.onConnectivityChanged.listen((_) {
      _debounce?.cancel();
      _debounce = Timer(debounceDuration, _emit);
    });
  }

  Future<void> stop() async {
    await _subscription?.cancel();
    _debounce?.cancel();
    await _controller.close();
  }

  Future<NetworkState> current() async {
    // Always recompute on demand
    return _computeAndMaybeEmit(push: false);
  }

  Future<void> _emit() async {
    await _computeAndMaybeEmit(push: true);
  }

  Future<NetworkState> _computeAndMaybeEmit({required bool push}) async {
    final results = await _connectivity.checkConnectivity();
    final type = _mapConnectivity(results);
    final online = await Reachability.hasInternet() ? OnlineStatus.online : OnlineStatus.offline;
    final state = NetworkState(type: type, online: online, checkedAt: DateTime.now());

    if (push && (_last == null || _hasMeaningfulChange(_last!, state))) {
      _last = state;
      if (!_controller.isClosed) _controller.add(state);
    }
    return state;
  }

  bool _hasMeaningfulChange(NetworkState a, NetworkState b) {
    return a.type != b.type || a.online != b.online;
  }

  ConnectionType _mapConnectivity(List<ConnectivityResult> results) {
    // connectivity_plus may return multiple results; pick the strongest.
    if (results.contains(ConnectivityResult.wifi)) return ConnectionType.wifi;
    if (results.contains(ConnectivityResult.ethernet)) return ConnectionType.ethernet;
    if (results.contains(ConnectivityResult.mobile)) return ConnectionType.mobile;
    return ConnectionType.none;
  }
}

Notes:

  • We broadcast a deduplicated stream so your app isn’t flooded with redundant events.
  • We debounce connectivity changes because some devices emit rapid toggles when switching networks.
  • We always verify reachability on every connectivity update.

Wiring It Into the UI

Here’s a minimal example using a StatefulWidget. In production, prefer dependency injection (Provider, Riverpod, or GetIt) and create a single app-wide instance.

class NetworkBannerDemo extends StatefulWidget {
  const NetworkBannerDemo({super.key});
  @override
  State<NetworkBannerDemo> createState() => _NetworkBannerDemoState();
}

class _NetworkBannerDemoState extends State<NetworkBannerDemo> {
  final _svc = NetworkStatusService();
  StreamSubscription<NetworkState>? _sub;
  NetworkState? _state;

  @override
  void initState() {
    super.initState();
    _svc.start();
    _sub = _svc.onStatusChange.listen((s) => setState(() => _state = s));
  }

  @override
  void dispose() {
    _sub?.cancel();
    _svc.stop();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final isOffline = !(_state?.isOnline ?? true);
    return Scaffold(
      appBar: AppBar(title: const Text('Network Status')),
      body: Column(
        children: [
          if (isOffline)
            const MaterialBanner(
              content: Text('You are offline. Some features may be unavailable.'),
              leading: Icon(Icons.wifi_off),
              backgroundColor: Colors.amber,
              actions: [SizedBox.shrink()],
            ),
          Expanded(
            child: Center(
              child: Text(
                _state == null
                    ? 'Checking network…'
                    : 'Type: ${_state!.type}\nOnline: ${_state!.isOnline}\nChecked: ${_state!.checkedAt.toLocal()}',
                textAlign: TextAlign.center,
              ),
            ),
          )
        ],
      ),
    );
  }
}

Offline-First UX Patterns

  • Cache aggressively: Wrap network calls with a repository that serves last-known-good data when offline.
  • Provide clear but unobtrusive feedback: A top banner or subtle status chip works better than repeated SnackBars.
  • Make actions retryable: Show a “Try again” button when a request fails due to connectivity.
  • Queue mutations: Store pending writes locally and sync when the device returns online.
  • Timeouts > Retries > Backoff: Prefer short timeouts with limited retries and exponential backoff to avoid battery drain.

Networking Guard for API Calls

Even with status signals, individual requests may fail for other reasons (server issues, TLS errors). Always handle per-request exceptions. A small helper can short-circuit calls when offline and standardize error messages.

typedef NetTask<T> = Future<T> Function();

class NetGuard {
  final NetworkStatusService network;
  NetGuard(this.network);

  Future<T> run<T>(NetTask<T> task) async {
    final state = await network.current();
    if (!state.isOnline) {
      throw const NetworkException('Device appears offline.');
    }
    try {
      return await task();
    } on TimeoutException {
      throw const NetworkException('Request timed out.');
    } catch (e) {
      throw NetworkException('Network error: $e');
    }
  }
}

class NetworkException implements Exception {
  final String message;
  const NetworkException(this.message);
  @override
  String toString() => message;
}

Platform Considerations

  • Web: Use HTTP reachability checks. Sockets are not available in the browser sandbox.
  • Android: No special permissions are required for connectivity_plus. Avoid long-running background pings.
  • iOS: Captive portals are common on public Wi‑Fi; your app may see Wi‑Fi connectivity but no internet. A 204 HEAD check is effective here.
  • Desktop: Socket checks are fast and accurate; still implement an HTTP fallback for restrictive firewalls.

Performance and Battery Tips

  • Debounce and deduplicate. Avoid checking reachability more than necessary.
  • Use short timeouts (1–3 seconds). Long timeouts make the app feel frozen.
  • Prefer HEAD to GET for reachability to reduce data transfer.
  • Batch checks: If many widgets need status, share a single stream rather than each widget polling.

Testing Strategy

  • Invert dependencies: Accept a Connectivity instance and a Reachability function so you can inject fakes in tests.
  • Unit tests: Simulate sequences like [none → wifi (no internet) → wifi (internet)] and assert the emitted NetworkState list.
  • Widget tests: Pump the widget with a fake stream and verify that your offline banner appears/disappears correctly.
  • Time control: Use fake timers or a controllable debounce duration (e.g., 0 ms) for deterministic tests.

Example fake injection for tests:

class FakeConnectivity extends Connectivity {
  final _controller = StreamController<List<ConnectivityResult>>.broadcast();
  List<ConnectivityResult> _current = const [ConnectivityResult.none];

  void emit(List<ConnectivityResult> results) {
    _current = results;
    _controller.add(results);
  }

  @override
  Future<List<ConnectivityResult>> checkConnectivity() async => _current;

  @override
  Stream<List<ConnectivityResult>> get onConnectivityChanged => _controller.stream;
}

class FakeReachability extends Reachability {
  static bool online = true;
  static @override Future<bool> hasInternet() async => online;
}

Inject these into NetworkStatusService during tests to fully control outcomes.

Putting It All Together

  • connectivity_plus tells you whether a network interface exists.
  • A reachability check validates real internet access.
  • Merge both into a single, debounced stream that your UI consumes.
  • Provide an offline-first UX with caching, retries, and clear messaging.

With this architecture, your Flutter app will behave predictably on flaky networks, recover quickly, and keep users informed without draining their battery.

Related Posts