Case Study: Network Awareness
Objective
Context: When a device loses connectivity, a naive implementation either crashes (uncaught network exception), freezes (spinner that never resolves), or silently drops the user's last action (mutation lost forever). None of these are acceptable in a production app.
This example demonstrates Qora's network-aware layer: queries pause automatically when offline and replay on reconnect, mutations are queued and replayed in FIFO order, and reconnect replay uses controlled concurrency so that a sudden flood of paused queries doesn't spike the server.
Key Features Demonstrated
NetworkMode.online(default): queries pause atFetchStatus.pausedwhen offline; replay automatically on reconnectNetworkMode.offlineFirst: serves cached data immediately, schedules background revalidation when back onlineNetworkMode.always: bypasses connectivity checks entirely (for local-DB-backed queries)FetchStatus.paused: surfaces the "waiting for connection" state to the UI without any custom booleanOfflineMutationQueue: FIFO queue; mutations fired offline are replayed in order on reconnectReconnectStrategy:maxConcurrent+jitterprevent thundering-herd when dozens of queries resume simultaneouslyNetworkStatusIndicator: automatic offline banner widget, zero configurationFlutterConnectivityManager: wiresconnectivity_plusintoQoraScopein one line
Architecture Highlights
Network awareness is orthogonal to query state. A query can be in Success state (data visible) while FetchStatus is paused (connectivity lost, background revalidation waiting). The widget tree is never blocked: it always has something to render.
main.dart
└── QoraScope(
client: QoraClient(config: reconnectStrategy + networkMode)
connectivityManager: FlutterConnectivityManager() ← plugs in connectivity_plus
lifecycleManager: FlutterLifecycleManager(...) ← refetch on resume
child: NetworkStatusIndicator( ← offline banner, zero config
child: MyApp(),
),
)
The QoraClient is configured with a ReconnectStrategy that controls how paused queries are replayed when connectivity returns:
final client = QoraClient(
config: QoraClientConfig(
defaultOptions: const QoraOptions(
networkMode: NetworkMode.online, // pause when offline, replay on reconnect
staleTime: Duration(minutes: 5),
),
reconnectStrategy: const ReconnectStrategy(
maxConcurrent: 5, // replay 5 queries at a time
jitter: Duration(milliseconds: 200), // random delay between batches
),
debugMode: kDebugMode,
),
);
runApp(
QoraScope(
client: client,
connectivityManager: FlutterConnectivityManager(),
lifecycleManager: FlutterLifecycleManager(qoraClient: client),
child: NetworkStatusIndicator(child: const MyApp()),
),
);
Implementation Details
ReconnectStrategy: thundering-herd prevention
Consider an app with 20 queries active across 8 screens. The user takes the subway for 20 minutes: all 20 queries pause. On reconnect, without a strategy, 20 simultaneous requests fire. If 200 users come out of the same subway station at the same time, the server receives 4,000 simultaneous requests.
ReconnectStrategy(maxConcurrent: 5, jitter: Duration(milliseconds: 200)) staggers replay into batches of 5, with a random delay (up to 200 ms) between each batch:
const ReconnectStrategy(
maxConcurrent: 5,
jitter: Duration(milliseconds: 200),
)
// Named constructors for common configurations:
ReconnectStrategy.instant() // all at once: useful for tests
ReconnectStrategy.conservative() // maxConcurrent: 2, jitter: 500ms: battery-conscious
The jitter is random per client instance: so even if 1,000 devices reconnect simultaneously, their replay windows are spread across a 200 ms window, distributing the load.
FetchStatus.paused: accurate UI state without custom flags
QoraBuilder exposes FetchStatus as its third builder argument. paused means "a fetch is queued but waiting for connectivity": distinct from fetching (actively running) and idle (nothing pending). This lets widgets render an accurate offline indicator without a single bool _isOffline:
QoraBuilder<List<Post>>(
queryKey: const ['posts'],
fetcher: api.getPosts,
builder: (context, state, fetchStatus) {
return switch (state) {
// First load, offline: nothing to show yet
Initial() || Loading(previousData: null) when fetchStatus == FetchStatus.paused =>
const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.wifi_off, size: 48, color: Colors.grey),
SizedBox(height: 12),
Text('No connection: connect to load posts'),
],
),
),
// First load, online: standard spinner
Initial() || Loading(previousData: null) =>
const Center(child: CircularProgressIndicator()),
// Data available: show feed regardless of connectivity
_ => Column(
children: [
// Paused: offline but data visible
if (fetchStatus == FetchStatus.paused)
const _OfflineBanner(),
// Fetching: SWR background revalidation running
if (fetchStatus == FetchStatus.fetching)
const LinearProgressIndicator(minHeight: 2),
if (state is Failure<List<Post>>)
_ErrorBanner(error: state.error),
Expanded(child: PostList(posts: state.dataOrNull!)),
],
),
};
},
)
Offline mutation queue: write now, sync later
Mutations fired while offline are enqueued and replayed in FIFO order on reconnect. The isOptimistic flag on MutationSuccess drives a "pending sync" visual so the UI is never misleading:
QoraMutationBuilder<Post, String, void>(
mutator: (content) => api.createPost(content),
options: MutationOptions(
offlineQueue: true,
optimisticResponse: (content) => Post.optimistic(content: content),
onSuccess: (_, __, ___) async =>
context.qora.invalidate(const ['posts']),
),
builder: (context, state, mutate) {
final isQueued = state is MutationSuccess && state.isOptimistic;
return Column(
children: [
if (isQueued)
const _SyncPendingBanner(), // "Will sync when online"
SubmitButton(
label: isQueued ? 'Queued' : 'Post',
icon: isQueued ? Icons.schedule : Icons.send,
onPressed: state.isPending ? null : () => mutate(_controller.text),
),
],
);
},
)
On reconnect, QoraClient replays the queue in FIFO order automatically. The onSuccess callback (which calls invalidate) fires only for real server confirmations (isOptimistic: false): never for the optimistic write.
NetworkMode.always for local-database queries
Not all queries need connectivity. A query backed by a local Hive box or SQLite database should never be paused:
QoraBuilder<Settings>(
queryKey: const ['settings'],
fetcher: localDb.getSettings, // reads from Hive: no network needed
options: const QoraOptions(
networkMode: NetworkMode.always, // never pauses, never waits for connectivity
staleTime: Duration(hours: 1),
),
builder: (context, state, _) { /* ... */ },
)
This snippet is not part of the example app, which only talks to a remote API.
Mix NetworkMode.always (local) and NetworkMode.online (remote) queries freely in the same screen: Qora handles them independently.
Source Code
examples/ex_05_network_aware/
├── lib/
│ ├── main.dart ← QoraClient + ReconnectStrategy + QoraScope
│ ├── features/
│ │ └── feed/
│ │ ├── data/feed_api.dart ← HTTP layer
│ │ ├── models/post.dart ← Post model
│ │ └── ui/
│ │ ├── feed_screen.dart ← FetchStatus.paused handling
│ │ └── compose_screen.dart ← OfflineMutationQueue + isOptimistic
│ └── shared/
│ ├── connectivity/simulated_connectivity_manager.dart ← online/offline toggle for the demo
│ └── widgets/
│ └── offline_banner.dart ← custom banner wrapped around NetworkStatusIndicator
└── pubspec.yaml
Related
- Network Aware Recipe:
NetworkMode,FetchStatus,OfflineMutationQueue, andReconnectStrategyin depth - Network Status:
NetworkStatusIndicatorandNetworkStatusBuilderwidgets - Offline-First Case Study: persistence across restarts (different concern from network awareness)
- QoraOptions API:
networkMode,refetchOnReconnect
Case Study: Offline-First Todo App
A todo app that keeps working without a network: saved data on start, queries that pause offline, and writes queued and replayed in order on reconnect.
Case Study: Hooks Integration
Replace QoraBuilder widget nesting with flat, composable hook calls: useQuery, useMutation, useInfiniteQuery, and global fetch indicators in a HookWidget