In Flutter, why can't connectivity_plus's onConnectivityChanged stream tell you whether your API is reachable, and how should an app use it instead?
answer
- radio status, not a round trip
- captive-portal Wi-Fi still says wifi
- a list of results since 6.0.1
- none only ever appears alone
- the real request is the probe
basics
~20 sconnectivity_plus reports which network interfaces are up (wifi, mobile, none), not whether packets reach your server. Captive portals, dead DNS or a down backend all look connected, so treat it as a hint and let the real request decide.
solid answer
~40 s`Connectivity().onConnectivityChanged` emits a `List<ConnectivityResult>` describing the active transports — `wifi`, `mobile`, `ethernet`, `vpn` and so on. It never makes a round trip, so hotel Wi-Fi behind a captive portal, a broken DNS server or your own backend being down all still report `wifi`. The plugin's own docs say not to use `checkConnectivity()` to decide whether a request will work. So I always send the request and handle timeouts and errors; I use the stream only as a hint — to show an "offline" banner when the list is just `[ConnectivityResult.none]`, and to retry immediately when an interface comes back instead of waiting out the backoff.
code
dart · 43 linesimport 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
class OfflineHint extends StatefulWidget {
const OfflineHint({super.key, required this.onInterfaceBack});
final VoidCallback onInterfaceBack;
@override
State<OfflineHint> createState() => _OfflineHintState();
}
class _OfflineHintState extends State<OfflineHint> {
StreamSubscription<List<ConnectivityResult>>? _sub;
bool _noInterface = false;
@override
void initState() {
super.initState();
_sub = Connectivity().onConnectivityChanged.listen((results) {
final up = results.hasConnectivity;
if (up && _noInterface) widget.onInterfaceBack(); // a hint: retry now
setState(() => _noInterface = !up);
});
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_noInterface) return const SizedBox.shrink();
return const ListTile(
leading: Icon(Icons.cloud_off),
title: Text('No network interface. Sending resumes when one returns.'),
);
}
}go deeper
Recall that the plugin reports interfaces such as wifi, mobile and none, and that a captive portal still reads as wifi. Say that the request's own result decides success.
Explain the list-shaped API, when none appears, the distinct filtering, and why the stream suits a banner and an early retry but not a gate on requests.
Show the production view: flapping NWPathMonitor values, missed Android background changes, debouncing the banner, and resetting backoff when an interface returns.
Frame reachability as something only the request can prove, and argue for UX that stays usable on a degraded link rather than hard offline modes keyed to a radio flag.
## What the plugin actually reports `connectivity_plus` (7.3 at the time of writing) is a federated plugin that asks each platform which **network interfaces** are currently usable. It exposes two entry points on the singleton `Connectivity()`: - `checkConnectivity()` — a one-off `Future<List<ConnectivityResult>>`. - `onConnectivityChanged` — a `Stream<List<ConnectivityResult>>` that emits whenever the set of transports changes. The plugin applies `Stream.distinct` with a list-equality check, so identical consecutive lists are dropped. The values are transports: `wifi`, `mobile`, `ethernet`, `vpn`, `bluetooth`, `satellite`, `other` and `none`. Since **6.0.1** the result is a **list**, because several transports can be active at once (for example `mobile` plus `vpn`). `none` only appears as the single element of the list when nothing is up, and 7.3.0 added a `hasConnectivity` extension on the list that encodes exactly that rule. A leftover pre-6 comparison such as `result == ConnectivityResult.wifi` now compares a list with an enum value and is always false; use `contains` instead. ## Why connected is not reachable None of this involves sending a packet to your server. The operating system knows a radio is associated and has an address; it does not know whether your API answers. The plugin's README says so directly: connection type availability does not guarantee internet access. | Situation | What the plugin reports | Does your API call work? | |---|---|---| | Hotel Wi-Fi before the captive-portal login | `[wifi]` | No | | One bar of signal at the edge of a field | `[mobile]` | Maybe, slowly, with drops | | DNS server down or corporate proxy blocking | `[wifi]` or `[ethernet]` | No | | Your backend is deploying or overloaded | anything | No, you get 5xx or a timeout | | Airplane mode | `[none]` | No | Only the last row is one where the plugin's answer is decisive — and even then, the interface can come back a second later. ## How to use it well The **request itself is the only reliable probe**. The pattern that holds up is: 1. Always send the request, with a timeout and error handling, whatever the plugin says. 2. Classify failures (connection error, timeout, 5xx, 4xx) and apply a retry policy to the retryable, idempotent ones. 3. Listen to `onConnectivityChanged` and, when the list goes from `[none]` to something with `hasConnectivity`, **wake up** pending retries now instead of waiting out a long backoff delay. 4. Use `[none]` to show a non-blocking "offline" hint, not to hard-block the UI. 5. If a screen truly needs a reachability answer, make a cheap request to **your own** backend with a short timeout and treat the answer as valid only for a moment. What you should not do is gate every call on `checkConnectivity()`: the plugin's own documentation says not to use that result to decide whether a request can succeed, and it adds a platform-channel call in front of every request. ## Platform caveats worth knowing - **Android**: since Android 8 connectivity broadcasts are not delivered to backgrounded apps, so the stream can miss changes while the app is in the background; re-check with `checkConnectivity()` when the app resumes. - **iOS simulator**: the stream may not update when Wi-Fi status changes — a known simulator-only issue. Test on a device. - **iOS and macOS**: `NWPathMonitor` can report flapping values such as `none` followed by `wifi` right after reconnecting. `distinct` removes repeats, not flaps, so debounce if the banner flickers. - **Web**: the plugin uses the browser's NetworkInformation API and falls back to `navigator.onLine`, which yields only `wifi` or `none`. - `vpn` is not reported on iOS and macOS; those platforms return `other` for it. ## In a field-survey app Picture surveyors submitting forms from farms with patchy coverage. The phone happily reports `[mobile]` while every request times out. If the submit button were disabled until the plugin said "online", it would look enabled and still fail; if it were gated on `wifi`, it would refuse to try on a usable cellular link. The robust design lets the submit run, surfaces a clear error with the form kept intact, retries safe calls with backoff, and uses the connectivity stream only to retry promptly when a signal returns. The subscription is created in `initState` and cancelled in `dispose`, like any other stream subscription held by a `State`.
- Why does connectivity_plus return a list instead of a single ConnectivityResult?Since 6.0.1 the plugin reports every active transport, because a device can be on `mobile` and `vpn` at once, or `mobile` plus `satellite` on newer iOS and Android. Code must use `contains` or the `hasConnectivity` extension; `none` appears only as the sole element when nothing is up.
- If a screen really needs to know the backend is reachable, what would you do?Make a cheap request to your own backend — a health or HEAD endpoint — with a short timeout, and treat success as "reachable right now" only. Probing a third-party host proves nothing about your API, and the answer goes stale immediately, so the real request still needs its own error handling.
- Why check connectivity again when an Android app returns to the foreground?The plugin's README notes that since Android 8 connectivity broadcasts are not delivered to backgrounded apps, so the stream may have missed a change. Calling `checkConnectivity()` on resume resynchronises the hint before the user acts on it.
The plugin is like checking that your phone shows signal bars: it tells you the radio is associated with a tower, not that the person you are calling will pick up. You only know that by dialling.
saying these in an interview costs you the question
- If connectivity_plus reports wifi, the API call is going to succeed.
- Gate every request on checkConnectivity() instead of handling network errors.
- ConnectivityResult.none can appear in the list alongside wifi.
- Comparing the result with == ConnectivityResult.wifi still works in current versions.
- The stream keeps delivering changes while an Android app is backgrounded.