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_plustells 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.wifion 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/healthanswers 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:
- 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
- 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_plusreports an interface, not reachability — captive portals, no-internet wifi, and a down backend all look "connected." - Use
connectivity_plusas a cheap gate, then confirm with a reachability probe to your own/healthendpoint (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.