flutter_network_guard

A complete network reliability toolkit for Flutter — connectivity detection, real internet reachability, server health checks, network quality scoring, retry with backoff, request deduplication/cancellation, an offline queue, an offline cache, and a set of network-aware widgets. Every feature is opt-in and state-management agnostic, so it drops into a Provider, Riverpod, BLoC, GetX, Cubit, ValueNotifier or plain setState app without asking you to change how you already manage state.

pub package License: MIT platforms


Table of contents

  1. Why this package
  2. What's included
  3. Installation
  4. Getting started
  5. Platform notes & known issues
  6. Core concepts
  7. Module-by-module guide with examples
    1. Connectivity detection
    2. Real internet reachability
    3. Server health checks
    4. Network quality
    5. Retry with backoff
    6. Protected calls with execute()
    7. Request deduplication
    8. Cancellation
    9. Offline queue
    10. Cache
    11. Network-aware widgets
    12. Logging
    13. Metrics
    14. Dio interceptor
    15. package:http client wrapper
  8. Full configuration reference
  9. Recipes
  10. Security & privacy notes
  11. Troubleshooting / FAQ
  12. What this package does not do
  13. Contributing
  14. License

Screenshots

Live demo Online Offline
Demo Online Offline

1. Why this package

Every mid-sized Flutter app eventually reinvents the same handful of network-handling utilities, usually badly, usually late:

  • "Is the device online?" gets answered by checking connectivity_plus alone, which only tells you whether there's a Wi-Fi or cellular link — not whether that link actually reaches the internet. Captive portals, misconfigured routers, and airline Wi-Fi login pages all report a "connected" link with zero real connectivity.
  • "Is the internet up?" and "is our API up?" get treated as the same question, when they're not. The rest of the internet can be fine while your backend is deploying.
  • A failed request gets retried with a hand-rolled for loop and a fixed Future.delayed, which hammers the server the moment it comes back up instead of backing off.
  • A user double-taps "Place order" and two orders get created because nothing was guarding against it.
  • The app goes offline mid-write, the request just throws, and the data the user typed is gone.

flutter_network_guard bundles solutions to all of the above into one consistent, well-tested API, and lets you turn on only the parts you need. Nothing in this package does anything until you opt into it — with zero configuration it only tracks connectivity and performs a real internet reachability check.

2. What's included

Module Purpose
Connectivity detection Wraps connectivity_plus, exposes a normalized ConnectivityType.
Internet reachability Confirms the internet is actually reachable, with multi-endpoint fallback.
Server health checks Monitors your own API/backend independently of general internet health.
Network quality Latency-based excellent / good / fair / poor classification.
Retry engine Fixed, linear, or exponential backoff, with jitter.
NetworkGuard.execute() One call that wires together offline checks, retry, dedup, cancellation, timeout, and caching, returning a typed result.
Request deduplication Prevents duplicate in-flight calls sharing a key.
Cancellation Cooperative CancelToken, usable with Future.any.
Offline queue Persist-and-replay queue for deferred writes, with priority and retry limits.
Cache In-memory TTL cache with five interaction policies, including stale-while-revalidate.
Network-aware widgets NetworkGuardBuilder, NetworkAware, NetworkStatusBanner, OfflineBanner, NetworkGuardButton, NetworkDebugPanel.
Logging & metrics Off by default; opt-in structured logging and in-memory counters.
Dio / package:http adapters Optional glue so your existing HTTP client short-circuits while offline.

3. Installation

Add the package to pubspec.yaml:

dependencies:
  flutter_network_guard: ^0.1.1

Then fetch it:

flutter pub get

flutter_network_guard pulls in four dependencies on your behalf: connectivity_plus, http, shared_preferences, and dio. You do not need to add any of these yourself, and you do not need Dio to use the package — it's only required if you choose to use the optional Dio interceptor.

Minimum SDKs: Dart ^3.13.0, Flutter >=1.17.0. If your project pins an older Dart SDK, bump environment.sdk in your pubspec.yaml before installing.

4. Getting started

The absolute minimum to start tracking network state:

import 'package:flutter/material.dart';
import 'package:flutter_network_guard/flutter_network_guard.dart';

Future<void> main() async {
  // Required because NetworkGuard.initialize() touches platform
  // channels (connectivity_plus) before runApp().
  WidgetsFlutterBinding.ensureInitialized();

  await NetworkGuard.initialize();

  runApp(const MyApp());
}

From anywhere in your app, after initialize() has completed:

if (NetworkGuard.instance.isOnline) {
  // Safe to fire requests.
}

That's the whole "quick start." Everything past this point — retry, offline queueing, caching, server health, widgets — is opt-in and explained module by module below, with a runnable example for each.

If you only take one thing from this README, take this: read section 7.6 next. execute() is the single call most apps end up using the most, and it ties together retry, dedup, cancellation and caching in one place.

5. Platform notes & known issues

This package has no platform channel code of its own — all platform behavior comes from connectivity_plus, http, and shared_preferences, so platform support tracks whatever those three currently support. As of this writing, that's Android, iOS, web, Windows, macOS, and Linux. A few things to be aware of on each:

  1. Android

    • No extra setup needed for connectivity/internet checks in most apps — connectivity_plus requires the ACCESS_NETWORK_STATE permission, which it merges into your manifest automatically via its own plugin manifest. You do not need to add it yourself.
    • If your app targets Android 9 (API 28) or newer and any of your internetCheckUrls or ServerHealthConfig.url values are plain http:// (not https://), the request will fail due to Android's default cleartext traffic block. Either use https:// endpoints (recommended) or add a network security config permitting cleartext for that specific host.
    • Emulators without Google Play services occasionally report a connected Wi-Fi link with no real upstream internet. This is exactly the scenario InternetChecker exists to catch — hasLocalConnection will be true while hasInternet is false. That's correct behavior, not a bug.
  2. iOS

    • No Info.plist changes are required for https:// endpoints — App Transport Security (ATS) allows HTTPS by default.
    • If you point internetCheckUrls or a ServerHealthConfig.url at a plain http:// endpoint, ATS will block it unless you add an ATS exception in Info.plist for that domain. As with Android, using https:// avoids this entirely and is strongly recommended.
    • iOS Simulator's network state can lag behind the host Mac's actual connectivity by a second or two after toggling Wi-Fi in System Settings — this is a Simulator quirk, not something this package can work around. Test connectivity transitions on a physical device before relying on the timing.
  3. Web

    • connectivity_plus on web reports based on the browser's navigator.onLine, which — like Android/iOS link state — tells you there's a connection, not that the internet is reachable. Real reachability still goes through InternetChecker, exactly as on other platforms.
    • Browsers enforce CORS. InternetChecker and ServerHealthChecker issue HEAD/GET requests directly from the browser, so the default internetCheckUrls (https://one.one.one.one, https://8.8.8.8) or any custom server-health URL must either allow cross-origin requests or you'll see the check reported as unreachable even though the endpoint is fine. In practice, pick a server-health endpoint on your own domain for web builds so CORS is under your control, and don't assume the public DNS-style defaults work identically in a browser vs. a mobile app.
    • SharedPreferencesQueueStorage works on web (backed by localStorage via the shared_preferences web implementation), but storage is scoped per-origin like any other browser storage — clearing site data clears the queue.
  4. Windows / Linux (desktop)

    • connectivity_plus desktop support reports connectivity type with less granularity than mobile (you'll frequently see ConnectivityType.ethernet or ConnectivityType.wifi correctly, but don't rely on ConnectivityType.vpn or ConnectivityType.bluetooth being detected reliably on every desktop OS version — treat those as best-effort).
    • No extra manifest/entitlement changes are needed for outbound HTTP(S) requests on Windows.
  5. macOS

    • Sandboxed macOS apps (the default for anything distributed through the Mac App Store) need the Outgoing Connections (Client) network entitlement, or every request this package makes — including the internet reachability check — will fail with a permission error that looks identical to "offline." If you're building a sandboxed macOS app, open macos/Runner/*.entitlements and add:
      <key>com.apple.security.network.client</key>
      <true/>
      
      This is a standard Flutter/macOS requirement unrelated to this package specifically, but it's the single most common "why does NetworkGuard always say I'm offline on macOS" report.
  6. All platforms — background/terminated app state

    • NetworkGuardConfig.checkOnResume re-checks network state when the app returns to the foreground. This package does not run checks while the app is fully backgrounded or terminated on any platform — there's no background isolate or platform-specific background task wired up. If you need connectivity awareness while backgrounded, that has to be built with platform-specific background execution (WorkManager, BGTaskScheduler, etc.) outside the scope of this package.

6. Core concepts

Before diving into individual modules, three ideas make the rest of this README click into place:

  1. Local connection, internet, and server health are three different signals, kept separate on purpose. NetworkInfo exposes hasLocalConnection, hasInternet, and serverReachable as independent fields rather than collapsing them into one boolean. A device can have Wi-Fi (hasLocalConnection: true) with no upstream internet (hasInternet: false). It can also have perfectly good internet while your API is down (serverReachable: false). Treating these as the same "offline" state produces confusing UX ("why does it say I'm offline, my Wi-Fi bars are full?") and wastes debugging time.
  2. Everything is opt-in. Calling NetworkGuard.initialize() with no config does exactly two things: tracks connectivity, and periodically confirms real internet reachability. It does not retry anything, queue anything, cache anything, check any server, or log anything unless you explicitly configure it to.
  3. execute() never throws. Instead of try/catch, calls made through NetworkGuard.instance.execute() return a sealed NetworkResult<T>NetworkSuccess, NetworkFailure, NetworkOffline, NetworkTimeout, NetworkCancelled, or NetworkQueued — so you switch on the outcome instead of wrapping every call site in error handling.

7. Module-by-module guide with examples

7.1 Connectivity detection

Read the current link-layer connection type, or listen for changes.

// One-off check, without going through NetworkGuard at all:
final service = ConnectivityService();
final type = await service.check(); // ConnectivityType.wifi, .mobile, ...

// Or, once NetworkGuard is initialized, just read the latest snapshot:
final info = NetworkGuard.instance.info;
print(info.type); // ConnectivityType

ConnectivityType values: wifi, mobile, ethernet, vpn, bluetooth, other, none, unknown. Each has a convenience getter:

info.type.hasLocalConnection; // false only for `none`

If the platform reports more than one simultaneous connection (e.g. Wi-Fi + VPN), ConnectivityService resolves them to a single type using this priority order: ethernet > wifi > vpn > mobile > bluetooth > other.

7.2 Real internet reachability

Link-layer connectivity is not internet access. InternetChecker performs an actual outbound request to confirm it:

final checker = InternetChecker(
  urls: ['https://one.one.one.one', 'https://8.8.8.8'],
  timeout: const Duration(seconds: 5),
);

final result = await checker.check();
print(result.isReachable); // true/false
print(result.latency);     // Duration?, present when reachable
print(result.url);         // which endpoint answered (or the last one tried)

URLs are tried in order; the first one that responds — with any status code, since the goal is confirming the network path works, not validating the endpoint's application logic — wins. If every URL fails, the result from the last attempt is returned so you can inspect result.error.

Once NetworkGuard is initialized, this runs automatically on the interval set by NetworkGuardConfig.checkInterval (default: 30 seconds), so you rarely need to call InternetChecker directly — it's exposed for the cases where you want a one-off check outside the regular monitoring loop.

7.3 Server health checks

Internet being reachable doesn't mean your backend is up. Configure one or more servers to monitor independently:

await NetworkGuard.initialize(
  config: NetworkGuardConfig(
    serverHealthChecks: [
      ServerHealthConfig(
        id: 'main-api',
        url: 'https://api.example.com/health',
        expectedStatusCodes: {200, 204},
        timeout: const Duration(seconds: 5),
      ),
      ServerHealthConfig(
        id: 'payments',
        url: 'https://payments.example.com/health',
      ),
    ],
    onServerRestored: (info) => debugPrint('A monitored server came back.'),
  ),
);

// Read the latest snapshot for every configured server:
final health = NetworkGuard.instance.serverHealth; // Map<String, ServerHealth>
print(health['main-api']?.isReachable);

// Or run a one-off check outside the regular monitoring loop:
final snapshot = await NetworkGuard.instance.checkServer(
  const ServerHealthConfig(id: 'main-api', url: 'https://api.example.com/health'),
);

Each ServerHealth snapshot carries isReachable, latency, statusCode, lastSuccessAt (null if it has never succeeded since monitoring started), and error.

7.4 Network quality

A latency-based estimate, not a bandwidth measurement — treat it as a useful signal, not ground truth. A high-latency but high-bandwidth satellite link and a genuinely poor connection can both report poor.

final info = NetworkGuard.instance.info;
print(info.quality); // NetworkQuality.excellent / .good / .fair / .poor / .unknown

Thresholds are configurable (each is an upper bound, inclusive):

await NetworkGuard.initialize(
  config: NetworkGuardConfig(
    qualityThresholds: NetworkQualityThresholds(
      excellent: const Duration(milliseconds: 80),
      good: const Duration(milliseconds: 250),
      fair: const Duration(milliseconds: 700),
      // anything slower than `fair` is classified as `poor`
    ),
  ),
);

If you want a smoothed reading instead of one noisy sample, use LatencyMonitor directly:

final monitor = LatencyMonitor(historySize: 5);
monitor.record(const Duration(milliseconds: 120));
monitor.record(const Duration(milliseconds: 340));
print(monitor.averageLatency); // average of the last 5 samples

7.5 Retry with backoff

RetryPolicy describes how retries are spaced; RetryEngine runs the loop. Most apps only ever touch RetryPolicy directly — RetryEngine is used internally by execute().

const policy = RetryPolicy(
  maxAttempts: 3,               // includes the first (non-retry) attempt
  initialDelay: Duration(seconds: 1),
  maxDelay: Duration(seconds: 10),
  strategy: RetryStrategy.exponential, // .fixed, .linear, or .exponential
  jitter: true,                 // ±25% randomization, avoids retry storms
  retryableStatusCodes: {408, 425, 429, 500, 502, 503, 504},
  retryIf: null,                // optional custom predicate, see below
);

// Delay before the retry after a given (1-indexed) attempt number:
policy.delayFor(1); // delay before the 2nd call
policy.delayFor(2); // delay before the 3rd call

Notes:

  1. RetryPolicy.none is a built-in constant equal to maxAttempts: 1 — use it to explicitly opt an execute() call out of retrying.
  2. 4xx client errors (400, 401, 403, 404, 422, ...) are not in the default retryableStatusCodes on purpose — retrying a bad request or an auth failure rarely helps and can duplicate side effects.
  3. Supply retryIf to fully override the retry decision with your own logic (e.g. inspecting a custom exception type from your HTTP client):
    RetryPolicy(
      retryIf: (error) => error is SocketException || error is TimeoutException,
    )
    

7.6 Protected calls with execute()

This is the main entry point most call sites use. It wires together an offline check, deduplication, cancellation, retry, timeout, and — if you pass a cache policy — caching, and returns a typed NetworkResult<T> instead of throwing.

final result = await NetworkGuard.instance.execute<List<Order>>(
  key: 'get_orders',
  retryPolicy: const RetryPolicy(maxAttempts: 3),
  timeout: const Duration(seconds: 10),
  request: () => api.getOrders(),
);

switch (result) {
  case NetworkSuccess(:final value, :final attempts, :final fromCache):
    // value is List<Order>; attempts tells you how many tries it took;
    // fromCache tells you if this came from CacheManager instead of a live call.
    break;
  case NetworkFailure(:final error, :final attempts):
    // Retries were exhausted (or maxAttempts: 1 and it failed once).
    break;
  case NetworkOffline():
    // Device was offline; `request` was never even called.
    break;
  case NetworkTimeout(:final attempts):
    // Exceeded the `timeout` duration.
    break;
  case NetworkCancelled():
    // A CancelToken was cancelled before completion.
    break;
  case NetworkQueued(:final taskId):
    // Only returned if you've wired execute() to your own queueing logic;
    // execute() itself returns NetworkOffline when offline — see the note below.
    break;
}

Every parameter:

Parameter Default Meaning
request — (required) The async call to run.
key null Enables deduplication and is required if you pass cachePolicy.
retryPolicy RetryPolicy.none How to retry on failure.
deduplicationPolicy DeduplicationPolicy.reuseInFlight How to handle a call whose key is already in flight.
cancelToken null A CancelToken the caller can cancel from elsewhere.
timeout null Per-attempt timeout; null means no timeout.
cachePolicy null Enables the CacheManager integration for this call — see 7.10.
cacheTtl null How long a value written by this call stays fresh.

Important behavior to know up front:

  1. If the device is offline when execute() is called and no cachePolicy is set, it returns NetworkOffline immediately — request is never invoked. This is deliberate: it avoids the confusing failure mode where a request half-starts, times out slowly, and only then reports "offline." If you want offline calls persisted for later delivery instead of immediately reported as offline, use the offline queueexecute() and the queue are separate, composable tools, not one feature.
  2. Non-idempotent writes should either skip retries or opt in carefully. Retrying a POST that actually reached the server but whose response was lost can create a duplicate. Either pass RetryPolicy.none for writes, or use retryIf together with an idempotency key your backend understands.
  3. A StateError is thrown synchronously (not wrapped in NetworkFailure) if you pass a cachePolicy other than CachePolicy.networkOnly without also passing a key — the cache needs a key to store under.

7.7 Request deduplication

Prevents the classic "user double-tapped Submit" bug. Used automatically inside execute() when you pass a key, or usable standalone:

final deduplicator = RequestDeduplicator();

Future<Order> submit() => deduplicator.run(
  'create_order',
  () => api.createOrder(),
  policy: DeduplicationPolicy.reuseInFlight,
);

Three policies:

  1. DeduplicationPolicy.reuseInFlight (default) — a second call with the same key while the first is still running gets the same result as the first, without starting a second network call.
  2. DeduplicationPolicy.ignoreNew — the second call is rejected immediately with a StateError; the first call is unaffected.
  3. DeduplicationPolicy.replacePrevious — the second call proceeds as a fresh request; the first is no longer tracked for dedup purposes (it still runs to completion in the background, it's just no longer considered "in flight").

Calls made with key: null are never deduplicated — dedup is opt-in per call, not global.

7.8 Cancellation

CancelToken is a cooperative cancellation primitive — it doesn't forcibly abort a socket (your HTTP client would need to support that itself), but it stops execute() from waiting on / reacting to a call once cancelled, and lets your own code check it at safe points:

final token = CancelToken();

final future = NetworkGuard.instance.execute<String>(
  cancelToken: token,
  request: () => api.longRunningReport(),
);

// Elsewhere, e.g. when the user navigates away:
token.cancel();

// Inside a long-running operation, check cooperatively between steps:
Future<void> longRunningReport(CancelToken token) async {
  for (final chunk in chunks) {
    token.throwIfCancelled(); // throws RequestCancelledException
    await processChunk(chunk);
  }
}

7.9 Offline queue

For writes that should be persisted and retried later instead of failing immediately while offline — the queue is a separate tool from execute(), and the two compose:

final queue = OfflineQueue(
  storage: const SharedPreferencesQueueStorage(), // survives app restarts
  maxQueueSize: 200,
  executor: (task) async {
    final response = await http.post(
      Uri.parse('https://api.example.com${task.endpoint}'),
      body: jsonEncode(task.body),
      headers: task.headers,
    );
    return response.statusCode == 200; // true = success, false = will retry
  },
);

await NetworkGuard.initialize(
  config: NetworkGuardConfig(
    offlineQueue: queue,
    autoProcessQueueOnRestore: true, // auto-runs queue.processPending() when internet returns
  ),
);

// Adding a task (e.g. the user tapped "save" while offline):
await NetworkGuard.instance.enqueue(
  QueuedRequest(
    id: 'update_profile_${DateTime.now().millisecondsSinceEpoch}',
    endpoint: '/profile',
    method: HttpMethod.put,
    body: {'displayName': 'Jane'},
    priority: QueuePriority.high,
    maxRetries: 5,
    expiresAt: DateTime.now().add(const Duration(days: 1)),
  ),
);

// Inspecting the queue:
NetworkGuard.instance.queue.pending;    // List<QueuedRequest>, priority-then-age ordered
NetworkGuard.instance.queue.failed;     // exhausted retries or expired
NetworkGuard.instance.queue.completed;  // succeeded, still retained until you clear them

// Manually retry everything that failed:
await NetworkGuard.instance.queue.retry();

// Remove a specific task, or wipe the queue entirely:
await NetworkGuard.instance.queue.remove('update_profile_123');
await NetworkGuard.instance.queue.clear();

Things worth knowing:

  1. Closures can't be serialized, so a QueuedRequest stores plain data (endpoint, method, body, headers) instead of "the function to call." Your executor turns that data back into a real request — this is intentional and is what makes persistence across app restarts possible in the first place.
  2. Queueing successfully is not a delivery guarantee. The device could stay offline indefinitely, a task can exceed maxRetries, or your backend could reject it once delivered. Treat this as "best-effort deferred delivery," and surface queue.failed in your UI so users aren't left wondering where their data went.
  3. SharedPreferencesQueueStorage stores the whole queue as one JSON blob. It's fine for the modest queue sizes this package targets, but avoid putting tokens, passwords, or other sensitive values in headers or body unless you provide your own encrypting QueueStorage implementation (see section 10).
  4. Two tasks added with the same id are deduplicated — the second add() replaces the first rather than creating a duplicate entry.
  5. Use InMemoryQueueStorage() (the default if you don't pass storage at all) for a session-only queue, or in tests.

7.10 Cache

An opt-in, in-memory cache with TTL and five interaction policies. Nothing is cached until you create a CacheManager and either call it directly or pass a cachePolicy to execute().

final cache = CacheManager(maxEntries: 100);

await NetworkGuard.initialize(config: NetworkGuardConfig(cacheManager: cache));

// Direct use:
cache.set('user_profile', profile, ttl: const Duration(minutes: 10));
final cached = cache.get<Profile>('user_profile'); // null if absent or expired

// Or through execute(), which requires `key` whenever cachePolicy is set:
final result = await NetworkGuard.instance.execute<Profile>(
  key: 'user_profile',
  cachePolicy: CachePolicy.staleWhileRevalidate,
  cacheTtl: const Duration(minutes: 10),
  request: () => api.getProfile(),
);

The five policies, in order of how "network-eager" they are:

  1. CachePolicy.networkOnly — always fetch fresh; cache is never read or written. Equivalent to not using the cache at all.
  2. CachePolicy.cacheFirst — return a cached value if present and unexpired; only fetch if there's nothing usable cached.
  3. CachePolicy.staleWhileRevalidate — return the cached value immediately if one exists (even if stale), while kicking off a background fetch to refresh it for next time. Ideal for "show something instantly, then update" screens like a dashboard.
  4. CachePolicy.networkFirst — always try a fresh fetch first, falling back to a cached value only if the fetch throws.
  5. CachePolicy.cacheOnly — return a cached value only, never fetch; throws a StateError if nothing is cached under that key.

Other useful CacheManager methods: invalidate(key), invalidateWhere((key) => key.startsWith('user_')), and clear(). Once maxEntries is exceeded, the single oldest entry (by write time) is evicted to make room — there's no LRU-by-access-time tracking, only insertion order.

7.11 Network-aware widgets

Five widgets, from lowest-level to highest-level:

NetworkGuardBuilder — rebuilds whenever network state changes. Every other widget in this list is built on top of this one.

NetworkGuardBuilder(
  builder: (context, info) => Text('Status: ${info.status.name}'),
)

NetworkAware — swaps between an online child and an offline fallback, for replacing an entire screen:

NetworkAware(
  child: const OrdersScreen(),
  offlineBuilder: (context, info) => OfflineScreen(info: info),
)

NetworkStatusBanner — a dismissible-feeling banner that appears while offline and briefly shows a "back online" message on reconnect, then disappears on its own:

Column(
  children: [
    const NetworkStatusBanner(), // put near the top of your Scaffold
    Expanded(child: MyScreenContent()),
  ],
)

// Fully custom appearance via `builder`:
NetworkStatusBanner(
  backOnlineDuration: const Duration(seconds: 2),
  builder: (context, info, isBackOnline) => MyCustomBanner(
    offline: !info.status.isOnline,
    justReconnected: isBackOnline,
  ),
)

OfflineBanner — a thin convenience wrapper around NetworkStatusBanner for a plain "offline only" indicator with no "back online" flash.

NetworkGuardButton — a tap target that disables itself while offline or while its own action is still in flight, and deduplicates rapid double-taps automatically:

NetworkGuardButton(
  onPressed: () => api.submitForm(),
  child: const Text('Submit'),
)

// Or fully custom, e.g. to show a spinner while `isLoading` is true:
NetworkGuardButton(
  onPressed: () => api.submitForm(),
  builder: (context, onPressed, isLoading) => ElevatedButton(
    onPressed: onPressed, // null (disabled) automatically while offline/loading
    child: isLoading ? const CircularProgressIndicator() : const Text('Submit'),
  ),
)

NetworkDebugPanel — a developer-only overlay of live network state, server health, and last-check time. Automatically hides itself in release builds unless you pass forceEnabled: true:

const NetworkDebugPanel() // shows in debug/profile builds only
const NetworkDebugPanel(forceEnabled: true) // shows everywhere, including release

7.12 Logging

Off by default — this package never writes a log line unless you turn it on:

await NetworkGuard.initialize(
  config: NetworkGuardConfig(
    enableLogging: true,
    logLevel: LogLevel.info, // none < error < warning < info < debug < verbose
  ),
);

Setting a level enables it and everything less verbose above it in the list — e.g. LogLevel.warning also emits error-level messages. LogLevel.none (the default) disables logging entirely regardless of what else is configured.

7.13 Metrics

Simple in-memory counters you drive yourself — nothing is sent anywhere, and nothing is recorded automatically unless you call these methods at the relevant points in your own code:

final metrics = NetworkMetrics();

metrics.recordRequest(success: true, attempts: 2, latency: const Duration(milliseconds: 340));
metrics.recordCache(hit: true);
metrics.recordQueued();

final snapshot = metrics.snapshot;
print(snapshot.totalRequests);
print(snapshot.successRate); // 0.0–1.0, or null if no requests yet
print(snapshot.retryCount);
print(snapshot.averageLatency);

metrics.reset(); // zero everything out, e.g. between test runs

7.14 Dio interceptor

Optional — only relevant if your app uses Dio. Makes doomed requests fail fast instead of hitting the network and timing out on their own while offline:

final dio = Dio()..interceptors.add(NetworkGuardInterceptor());

NetworkGuardInterceptor({failFastWhenOffline = true}) rejects requests immediately with a DioException wrapping a NetworkUnavailableException while offline. Retry, deduplication, and offline queueing for Dio calls are handled better by wrapping the call in NetworkGuard.instance.execute() than inside the interceptor, since those features need to know per-call semantics (idempotency, a dedup key) that an interceptor can't infer generically:

final result = await NetworkGuard.instance.execute(
  request: () => dio.get('/orders'),
  retryPolicy: const RetryPolicy(maxAttempts: 3),
);

7.15 package:http client wrapper

The equivalent adapter for package:http users — wrap your existing client and every call through it will fail fast while offline:

final client = NetworkGuardHttpClient(http.Client());
final response = await client.get(Uri.parse('https://api.example.com/orders'));

Same failFastWhenOffline parameter and the same recommendation: use NetworkGuard.instance.execute() around individual calls for retry, dedup, and cancellation rather than trying to put that logic in the wrapper itself.

8. Full configuration reference

Every field of NetworkGuardConfig, passed to NetworkGuard.initialize():

Field Default Notes
enableMonitoring true Set false to disable the continuous background stream; you'd then call checks manually.
enableLogging false See 7.12.
logLevel LogLevel.error Minimum level emitted when enableLogging is true.
checkInterval 30s How often background monitoring re-checks internet/server health.
checkDebounceDuration 300ms Minimum gap between checks triggered by rapid OS connectivity events.
internetCheckUrls Cloudflare + Google DNS Fallback list for InternetChecker. Must not be empty.
internetCheckTimeout 5s Per-endpoint timeout for internet checks.
qualityThresholds see 7.4 Latency boundaries for NetworkQuality.
checkOnResume true Re-check when the app returns to the foreground.
serverHealthChecks [] List of ServerHealthConfig to monitor continuously.
eventHistorySize 50 How many entries NetworkGuard.instance.history retains.
offlineQueue null See 7.9.
autoProcessQueueOnRestore true Auto-calls queue.processPending() when internet returns; only matters if offlineQueue is set.
cacheManager null See 7.10.
onOnline null Fires on transition into NetworkStatus.online.
onOffline null Fires on transition into NetworkStatus.offline.
onInternetRestored null Fires when internet comes back after being down — a good place to flush a queue manually if autoProcessQueueOnRestore is off.
onServerRestored null Fires when any monitored server comes back after being down.
onNetworkChanged null Fires on every NetworkInfo change, regardless of type.

9. Recipes

1. Show a full-screen offline state instead of a broken list:

NetworkAware(
  child: const OrdersListScreen(),
  offlineBuilder: (context, info) => Center(
    child: Text(
      info.hasLocalConnection
          ? 'Connected, but no internet access.'
          : 'No connection.',
    ),
  ),
)

2. Fetch-with-fallback for a settings screen that should still render something useful offline:

final result = await NetworkGuard.instance.execute<Settings>(
  key: 'user_settings',
  cachePolicy: CachePolicy.staleWhileRevalidate,
  cacheTtl: const Duration(hours: 1),
  request: () => api.getSettings(),
);
final settings = result.valueOrNull ?? Settings.defaults();

3. Guard a non-idempotent "place order" button against duplicate submissions and offline taps in one line:

NetworkGuardButton(
  onPressed: () async {
    final result = await NetworkGuard.instance.execute(
      key: 'place_order_${cart.id}',
      retryPolicy: RetryPolicy.none, // do not auto-retry a write
      request: () => api.placeOrder(cart),
    );
    if (result is NetworkOffline) {
      await NetworkGuard.instance.enqueue(
        QueuedRequest(id: 'order_${cart.id}', endpoint: '/orders', body: cart.toJson()),
      );
    }
  },
  child: const Text('Place order'),
)

10. Security & privacy notes

  1. This package makes no network calls of its own beyond what you configure: internet reachability checks against internetCheckUrls, server health checks against the endpoints you list, and whatever your own request closures do inside execute(). It does not phone home, collect analytics, or transmit anything to Anthropic, Google, or any third party on your behalf.
  2. SharedPreferencesQueueStorage persists queued task bodies and headers as plain, unencrypted JSON. Do not put access tokens, passwords, or other sensitive values into a QueuedRequest.headers or .body unless you implement your own QueueStorage backed by encrypted storage (e.g. flutter_secure_storage) — the QueueStorage interface is deliberately small (loadAll / saveAll / clear) so swapping it out is straightforward.
  3. Logging (when explicitly enabled) prints endpoint URLs and task ids at info level and above — it does not log request bodies or headers. Still, avoid enabling LogLevel.debug/.verbose in production builds that ship to end users.

11. Troubleshooting / FAQ

"NetworkGuard.instance throws a StateError." You called it before await NetworkGuard.initialize() finished. Make sure initialize() is awaited in main() before runApp(), and that nothing reads NetworkGuard.instance from a top-level variable initializer that runs earlier than that.

"It says I'm offline but my Wi-Fi is clearly connected." Check NetworkGuard.instance.info.hasLocalConnection vs .hasInternet separately — a connected link with no real internet (captive portal, router with no upstream) is exactly the case InternetChecker is supposed to catch. If hasInternet is genuinely wrong on your network, check whether your internetCheckUrls are reachable from that network specifically (corporate firewalls sometimes block 1.1.1.1/8.8.8.8 directly) — point internetCheckUrls at your own domain if so.

"Requests aren't retrying." execute() only retries if you pass a retryPolicy with maxAttempts > 1 — the default, RetryPolicy.none, is exactly one attempt. Also check that the failure is actually reaching RetryEngine: a 4xx status your own code throws as an exception is retried according to retryIf (if you supplied one) or the package's default heuristic, not retryableStatusCodes directly — that set is meant to be read by your own retryIf when you're inspecting an HTTP response status yourself.

"Two calls to execute() with the same key both hit the network." Deduplication only kicks in while the first call is genuinely still in flight. If the first call already completed (even a moment ago) before the second starts, there's nothing to deduplicate against — that's two sequential calls, not a duplicate.

"The offline queue isn't processing automatically." Confirm autoProcessQueueOnRestore is true (it is by default) and that you actually configured offlineQueue in NetworkGuardConfig — without it, NetworkGuard.instance.queue throws a StateError rather than silently doing nothing.

"macOS app always reports offline." See the sandboxing entitlement note in section 5 — this is almost always a missing com.apple.security.network.client entitlement, not a bug in this package.

12. What this package does not do

Being upfront about scope:

  • It does not measure bandwidth/throughput — NetworkQuality is a latency estimate only.
  • It does not guarantee delivery of queued requests — see the note in 7.9.
  • It does not run any checks while your app is backgrounded or terminated, on any platform.
  • It does not forcibly abort an in-flight socket on cancellation — that depends on your underlying HTTP client's own cancellation support.
  • It is not a replacement for server-side idempotency keys on writes — NetworkRequest.idempotencyKey gives you a place to carry one, but you still need your backend to honor it.

13. Contributing

Issues and pull requests are welcome on the issue tracker. Please include your Flutter/Dart version and target platform when filing a bug — a large share of "why doesn't this work" reports turn out to be platform-specific setup (see section 5) rather than package bugs.

14. License

MIT — see LICENSE.