← Back to blog
Offline-First Flutter · Part 5 of 12
October 1, 20269 min read

Offline-First Flutter, Part 5: Detecting Connectivity (the Right Way)

FlutterDartOffline-First

Detecting Connectivity

This is Part 5 of the Offline-First Flutter series. FieldNotes is a working offline app (Part 4). Now we build the trigger for the sync engine: a reliable answer to "are we online right now?" so we know when to attempt a sync.

This sounds trivial — "just use connectivity_plus" — and that assumption causes a classic bug:

Being connected to a network is NOT the same as having working internet. connectivity_plus tells you a network interface exists (wifi/cellular). It does not tell you packets reach your server. Airplane wifi, captive portals, "connected, no internet," a dead DNS, or your backend being down all report as "connected." Trust that signal blindly and your app insists it's online while every request times out.

Let's build a signal you can actually trust.

Stack: connectivity_plus + Riverpod, Flutter 3.38 / Dart 3.12. Builds on the repository.


The analogy: bars vs a connected call

Analogy — phone bars. Your phone showing full bars means a tower is in range — a connection exists. It does not mean your call will connect; the network could be congested, the other party's phone off, the line down. To know you can talk, you have to place the call and hear someone answer.

  • connectivity_plus = the bars. "There's an interface."
  • A reachability check = placing the call. "My server actually answered."

Offline-first needs the second. We use the first as a cheap hint and confirm with the second.


Layer 1: connectivity_plus — the cheap hint

connectivity_plus reports the active network interface and emits when it changes. It's fast, event-driven, and battery-cheap — perfect as a first filter:

import 'package:connectivity_plus/connectivity_plus.dart';

// Emits whenever the interface changes (wifi ↔ cellular ↔ none).
Stream<List<ConnectivityResult>> get changes =>
    Connectivity().onConnectivityChanged;

// One-shot check:
Future<bool> get hasInterface async {
  final result = await Connectivity().checkConnectivity();
  return !result.contains(ConnectivityResult.none);
}

What it's good for: detecting the transition from "no interface" to "some interface" — a strong cue to attempt a reachability check and maybe sync. What it's NOT good for: concluding you're online. ConnectivityResult.wifi on captive-portal airport wifi is a lie.


Layer 2: reachability — place the call

To actually confirm internet, make a tiny request — ideally to your own backend (so you're confirming the thing you'll sync with), with a short timeout:

import 'package:http/http.dart' as http;

class ReachabilityService {
  ReachabilityService(this._baseUrl);
  final Uri _baseUrl;

  /// Hits a cheap health endpoint. Returns true only if the SERVER answered.
  Future<bool> canReachServer() async {
    try {
      final res = await http
          .head(_baseUrl.resolve('/health'))   // HEAD = no body, minimal cost
          .timeout(const Duration(seconds: 4)); // don't hang forever
      return res.statusCode >= 200 && res.statusCode < 400;
    } catch (_) {
      return false; // timeout, DNS failure, socket error, captive portal, etc.
    }
  }
}

Why hit your own server, not google.com: you don't care whether Google is reachable — you care whether your backend is, because that's what the sync engine talks to. A captive portal might let you reach a portal page but not your API; your server being down should count as "offline for sync purposes." Checking your own /health answers the question that matters.


Combining them into one trustworthy signal

The pattern: let connectivity_plus cheaply gate when you bother to check reachability. No interface → definitely offline (skip the request). Interface present → confirm with a reachability probe.

class OnlineStatusService {
  OnlineStatusService(this._reachability);
  final ReachabilityService _reachability;

  /// A debounced stream of true/false "really online" values.
  Stream<bool> watch() async* {
    await for (final results in Connectivity().onConnectivityChanged) {
      final hasInterface = !results.contains(ConnectivityResult.none);
      if (!hasInterface) {
        yield false;                          // no interface → certainly offline
      } else {
        yield await _reachability.canReachServer(); // interface → confirm
      }
    }
  }
}

Expose it as a Riverpod provider:

final onlineProvider = StreamProvider<bool>((ref) {
  final reach = ReachabilityService(ref.watch(baseUrlProvider));
  return OnlineStatusService(reach).watch();
});

Now any widget can show an offline banner, and — more importantly — the sync engine can listen for the offline → online transition.


Triggering sync on reconnect

The whole reason we built this: when we come back online, kick the sync engine (Part 6 onward). With Riverpod's listen, that's a few lines:

// Somewhere central (e.g. a syncCoordinatorProvider):
ref.listen(onlineProvider, (previous, next) {
  final wasOffline = previous?.value == false;
  final isOnline = next.value == true;
  if (wasOffline && isOnline) {
    ref.read(syncEngineProvider).syncNow(); // reconnected → push + pull
  }
});

React to the edge, not the level. Sync on the transition to online (wasOffline && isOnline), not on every "online" emission — otherwise a flapping connection triggers a sync storm. We also debounce (below) and let the sync engine itself be idempotent and safe to call often (Part 6).


Handling flapping connections

Real networks flap — wifi drops for 2 seconds in an elevator and returns. Reacting to every blip wastes battery and can interleave half-finished syncs. Two defenses:

  1. Debounce the online signal so brief drops don't propagate:
// Only emit "online" after it's been stable for a moment.
stream.debounceTime(const Duration(seconds: 2)); // e.g. via rxdart
  1. Make the sync engine resilient regardless. The connectivity signal is a hint, never a guarantee. The sync engine must assume any request can still fail mid-flight (the signal said "online" a second ago) and handle it gracefully — retries, partial progress, idempotency. We design exactly that in Part 6.

The mindset: connectivity detection is best-effort optimization — it tells you when it's probably worth trying. Correctness must not depend on it being right. Treat "online" as "maybe try now," and build a sync engine that's safe even when the hint is wrong.


Showing it in the UI

A small but real UX win — an offline banner from the same provider:

class OfflineBanner extends ConsumerWidget {
  const OfflineBanner({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final online = ref.watch(onlineProvider).value ?? true;
    if (online) return const SizedBox.shrink();
    return Container(
      width: double.infinity,
      color: Colors.orange.shade700,
      padding: const EdgeInsets.all(6),
      child: const Text('Offline — changes will sync when you reconnect',
          textAlign: TextAlign.center),
    );
  }
}

Because writes already work offline (Part 4), this banner is reassurance, not an error. The user keeps working; the banner just says "we've got your back."


Practice Challenges

Challenge 1 — The captive-portal bug. A user is on airport wifi behind a login page. connectivity_plus says wifi. Your app shows "online" and every sync times out. Diagnose and fix.

Show solution

connectivity_plus only confirms an interface, not reachability — the captive portal blocks your API. Add a reachability probe to your own /health endpoint with a short timeout, and treat "online" as interface present AND server reachable. The portal then correctly reads as offline-for-sync.

Challenge 2 — Why your own server? Why probe your backend instead of https://google.com?

Show solution

The sync engine talks to your backend, so that's what must be reachable. Google being up doesn't mean your API is (it could be down, or blocked by a portal that allows general browsing). Probing your own /health answers the question that actually matters for sync.

Challenge 3 — Edge vs level. Why trigger sync on wasOffline && isOnline rather than whenever the signal is true?

Show solution

To sync on the transition to online (the edge), not repeatedly while online (the level). Reacting to every "online" emission — especially with a flapping connection — causes redundant sync storms. The reconnect edge is the meaningful event.

Challenge 4 — Flapping. An elevator drops wifi for 2 seconds repeatedly. What two defenses keep this from wrecking sync?

Show solution

(1) Debounce the online signal so brief drops/returns don't propagate as events. (2) Make the sync engine resilient — idempotent, retry-capable, safe to call often — so even if a sync starts during a blip, it fails gracefully and resumes later. Connectivity is a hint, not a guarantee.

Challenge 5 — Correctness vs hint. Why must sync correctness not depend on the connectivity signal being accurate?

Show solution

The signal is best-effort — it can say "online" a moment before a request fails (or vice versa). If correctness relied on it, a wrong hint would corrupt sync. Instead, treat it as "probably worth trying now" and build the sync engine to handle any request failing, so a wrong hint costs at most a wasted attempt.


Questions to test yourself

Q1 (basic). What does connectivity_plus actually tell you?

Show answer

That a network interface exists and its type (wifi/cellular/none), and when it changes. It does not confirm that the internet — or your server — is actually reachable.

Q2 (basic). What is a reachability check and how do you do one?

Show answer

A tiny request (e.g. an HTTP HEAD to your /health endpoint) with a short timeout that confirms your server actually answered. Success = genuinely online for sync; any failure/timeout = treat as offline.

Q3 (intermediate). How do you combine the two layers into one signal?

Show answer

Use connectivity_plus as a cheap gate: if there's no interface, you're offline (skip the probe). If there is, run a reachability check and use its result. "Online" = interface present and server reachable.

Q4 (intermediate). Why probe your own backend rather than a generic site?

Show answer

Because the sync engine communicates with your backend specifically. Reaching a generic site doesn't prove your API is up or unblocked by a captive portal. Probing your own server tests the exact dependency sync relies on.

Q5 (advanced). Why should sync be triggered on the offline→online edge and be idempotent regardless of the signal?

Show answer

Triggering on the edge avoids redundant syncs while continuously online and during flapping. Making sync idempotent/resilient means a wrong or stale connectivity hint (e.g. "online" right before a failure) can't corrupt state — at worst it wastes an attempt. Connectivity is an optimization hint; correctness lives in the sync engine.

Q6 (advanced). Why is an offline banner reassurance rather than an error in this architecture?

Show answer

Because writes already succeed offline (local-first, Part 4). Being offline doesn't block the user or lose data — changes are saved locally and marked dirty for later sync. The banner just communicates that syncing is paused, so it's informational, not a failure state.


Wrapping up

  • Connected ≠ online. connectivity_plus reports an interface, not reachability — captive portals, no-internet wifi, and a down backend all look "connected."
  • Use connectivity_plus as a cheap gate, then confirm with a reachability probe to your own /health endpoint (short timeout). "Online" = interface and server reachable.
  • Expose one trustworthy onlineProvider, and trigger sync on the offline→online edge (not every emission).
  • Defend against flapping with debouncing, and — crucially — make the sync engine resilient so correctness never depends on the connectivity hint.
  • Offline status is reassurance, not an error, because writes already work locally.

In Part 6 we build the first half of the sync engine: the outbox / operation queue — how to push local changes to the server reliably and exactly once, even across crashes and flaky networks.