Network Aware
Qora provides network-aware behavior for queries and mutations. When connectivity is lost, Qora can pause fetches, queue mutations, and replay operations after reconnect.
Overview
Network awareness in Qora is built on three orthogonal primitives:
| Primitive | What it controls |
|---|---|
NetworkMode | How a query or mutation behaves when offline |
FetchStatus | Whether a fetch is actively running, paused, or idle |
OfflineMutationQueue | FIFO queue that replays queued writes on reconnect |
They are fully independent from each other. You can use NetworkMode.online (pause queries) without enabling the offline mutation queue, or use the queue on mutations while letting queries use NetworkMode.always.
NetworkMode
NetworkMode is a per-query / per-mutation enum that tells Qora what to do when the device has no connectivity:
enum NetworkMode {
/// Default. Pause the fetch while offline and replay it on reconnect.
/// The state becomes Loading(previousData: <cached data or null>) and
/// FetchStatus becomes paused until connectivity returns.
online,
/// Always execute regardless of connectivity.
/// Useful when the fetcher uses a local database or cache.
always,
/// Serve cached data immediately, then schedule a background revalidation
/// when the device is back online.
offlineFirst,
}
Setting networkMode per query
final user = await client.fetchQuery<User>(
key: ['users', userId],
fetcher: () => api.getUser(userId),
options: const QoraOptions(networkMode: NetworkMode.online), // default
);
Setting networkMode globally
final client = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(
networkMode: NetworkMode.online,
),
),
);
Behaviour by mode
| Mode | Offline behaviour | On reconnect |
|---|---|---|
online (default) | Fetch is paused, FetchStatus.paused emitted | Replayed automatically |
always | Fetch runs: fails if no connectivity | Not retried on reconnect |
offlineFirst | Cached data returned immediately | Background revalidation queued |
NetworkMode.always for queries backed by a local SQLite database or Hive box: they never need connectivity and should never be paused.FetchStatus: the second axis
QoraState<T> describes what you have (initial / loading / success / failure). FetchStatus describes what Qora is currently doing to get it:
enum FetchStatus {
/// A network request is in flight.
fetching,
/// Queued for fetch but offline: will resume on reconnect.
paused,
/// No active fetch (data is fresh or no fetch has started yet).
idle,
}
These two values form a two-dimensional grid. The most useful combinations are:
QoraState | FetchStatus | Meaning |
|---|---|---|
Loading | fetching | First fetch running |
Loading (no previousData) | paused | Offline, nothing cached: the fetch waits for the network |
Loading (with previousData) | paused | Offline, online mode: refetch waiting, cached data still available |
Success | fetching | Background revalidation running |
Success | paused | Offline, offlineFirst mode: cached data served, refetch queued |
Success | idle | Data is fresh: nothing running |
Failure | idle | Last fetch failed, not retrying |
Observing FetchStatus in Dart
client.watchFetchStatus(['users', userId]).listen((status) {
switch (status) {
case FetchStatus.fetching:
print('Fetching…');
case FetchStatus.paused:
print('Offline: will retry when back online');
case FetchStatus.idle:
print('Done');
}
});
Observing FetchStatus in Flutter
QoraBuilder receives FetchStatus as its third builder argument:
QoraBuilder<User>(
queryKey: ['users', userId],
fetcher: () => api.getUser(userId),
builder: (context, state, fetchStatus) {
// Show a "paused" chip when offline and waiting to fetch
final isPaused = fetchStatus == FetchStatus.paused;
return switch (state) {
Initial() => const SizedBox.shrink(),
Loading() => isPaused
? const OfflineWaitingIndicator()
: const CircularProgressIndicator(),
Success(:final data) => Column(
children: [
UserCard(data),
if (isPaused) const RefreshingOfflineChip(),
],
),
Failure(:final error) => ErrorBanner(error),
};
},
)
Offline Mutation Queue
The OfflineMutationQueue holds write operations that could not be sent while offline and replays them in FIFO order when the device reconnects.
Enabling the queue
The queue is automatically created and attached to QoraClient. In Flutter, QoraMutationBuilder injects it automatically. You only need to opt-in per mutation via MutationOptions:
QoraMutationBuilder<void, Todo, void>(
mutator: (todo) => api.createTodo(todo),
options: MutationOptions(
// Opt this mutation into the offline queue
offlineQueue: true,
// Provide an optimistic response so the UI updates immediately
optimisticResponse: (todo) => todo,
),
builder: (context, state, mutate) {
return AddTodoButton(
onTap: () => mutate(newTodo),
);
},
)
Offline flow: step by step
When offlineQueue: true and the device is offline:
mutate(variables)
│
├── offlineQueue: true & offline?
│ YES
│ ├── Enqueue PendingMutation (FIFO)
│ ├── optimisticResponse provided?
│ │ YES → emit MutationSuccess(isOptimistic: true)
│ │ NO → stay MutationPending
│ └── ← return to caller
│
└── NO (online) → execute normally → MutationSuccess(isOptimistic: false)
On reconnect, QoraClient replays the queue:
reconnect detected
│
├── Replay paused queries (batched, with jitter)
└── Replay OfflineMutationQueue (FIFO)
│
├── each mutation runs once, in order
├── success → onSuccess, invalidates, onSettled
└── failure → onError (rollback), onSettled, next mutation
isOptimistic: styling unconfirmed mutations
When optimisticResponse is provided, the mutation emits MutationSuccess(isOptimistic: true). Use this flag in the UI to render a "pending" visual state (greyed out, clock icon, strikethrough) for items that have not yet been confirmed by the server:
QoraMutationBuilder<void, Todo, void>(
mutator: api.createTodo,
options: MutationOptions(
offlineQueue: true,
optimisticResponse: (todo) => todo,
onSuccess: (_, todo, __) async {
// Runs for REAL success (isOptimistic: false) only after server confirms
context.qora.invalidate(['todos']);
},
),
builder: (context, state, mutate) {
// Check isOptimistic on the MutationSuccess state
final isOptimistic = switch (state) {
MutationSuccess(:final isOptimistic) => isOptimistic,
_ => false,
};
return Column(
children: [
if (isOptimistic)
const Row(
children: [
Icon(Icons.schedule, size: 14),
SizedBox(width: 4),
Text('Queued: will sync when online'),
],
),
SubmitButton(
onTap: state.isPending ? null : () => mutate(newTodo),
),
],
);
},
)
Accessing the queue directly
// How many mutations are pending replay?
final count = context.qora.offlineMutationQueue.length;
// Inspect the pending mutations
for (final pending in context.qora.offlineMutationQueue.snapshot) {
print('${pending.mutatorId} enqueued at ${pending.enqueuedAt}');
}
// Manually trigger a replay (normally done automatically on reconnect)
final result = await context.qora.offlineMutationQueue.replay();
print('Succeeded: ${result.succeeded}, Failed: ${result.failed}');
When a replayed mutation fails
Each queued mutation is sent once, in order, through the same path as an online call. When the server rejects one, onError receives the context returned by onMutate at enqueue time, so the rollback you wrote for the online case also covers the offline one. Replay then continues with the next mutation.
A failed replay is not re-enqueued. Surface it from onError (a snackbar, a retry button) rather than relying on the next reconnect.
Keep queued mutations independent where you can. When one depends on another (create a todo, then complete it), the second one fails if the first was rejected, and its own onError rolls it back.
ReconnectStrategy: thundering herd prevention
When connectivity returns and dozens of queries are paused, replaying them simultaneously floods the server. ReconnectStrategy introduces controlled concurrency and jitter:
final client = QoraClient(
config: QoraClientConfig(
reconnectStrategy: const ReconnectStrategy(
maxConcurrent: 5, // Replay 5 queries at a time
jitter: Duration(milliseconds: 200), // Random delay between batches
),
),
);
Named constructors
// Default: 5 concurrent, 100 ms jitter
const ReconnectStrategy()
// All queries replay simultaneously (useful for testing)
ReconnectStrategy.instant()
// Conservative: 2 concurrent, 500 ms jitter
ReconnectStrategy.conservative()
Flutter Setup
1. Install connectivity_plus
dependencies:
connectivity_plus: ^6.0.0
2. Wire up QoraScope
import 'package:flutter/foundation.dart';
import 'package:qora_flutter/qora_flutter.dart';
void main() {
final client = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(
staleTime: Duration(minutes: 5),
networkMode: NetworkMode.online,
),
reconnectStrategy: ReconnectStrategy(
maxConcurrent: 5,
jitter: Duration(milliseconds: 100),
),
debugMode: kDebugMode,
),
);
runApp(
QoraScope(
client: client,
lifecycleManager: FlutterLifecycleManager(qoraClient: client),
// No QoraClient reference needed: pure signal provider
connectivityManager: FlutterConnectivityManager(),
child: const MyApp(),
),
);
}
3. Add NetworkStatusIndicator
Wrap your app (or any sub-tree) with NetworkStatusIndicator to show an offline banner automatically:
QoraScope(
client: client,
connectivityManager: FlutterConnectivityManager(),
child: NetworkStatusIndicator(
child: const MyApp(),
),
)
This renders a thin dark bar at the bottom of the screen saying "Offline mode" whenever NetworkStatus.offline is detected.
Custom offline banner
NetworkStatusIndicator(
offlineBanner: (context) => Container(
color: Colors.orange,
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.wifi_off, color: Colors.white, size: 16),
SizedBox(width: 8),
Text('No connection', style: TextStyle(color: Colors.white)),
],
),
),
child: const MyApp(),
)
Full builder control
Use builder when you need to react to NetworkStatus.online or NetworkStatus.unknown as well:
NetworkStatusIndicator(
builder: (context, status, child) {
return Stack(
children: [
child!,
if (status == NetworkStatus.offline)
const Positioned(
bottom: 0, left: 0, right: 0,
child: MyOfflineBanner(),
),
if (status == NetworkStatus.unknown)
const Positioned(
bottom: 0, left: 0, right: 0,
child: MyCheckingBanner(),
),
],
);
},
child: const MyApp(),
)
Complete Example: Offline-First Todo App
import 'package:flutter/material.dart';
import 'package:qora_flutter/qora_flutter.dart';
class TodoScreen extends StatelessWidget {
const TodoScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: QoraBuilder<List<Todo>>(
queryKey: const ['todos'],
fetcher: api.getTodos,
builder: (context, state, fetchStatus) {
// Paused → offline, waiting to load for the first time
if (fetchStatus == FetchStatus.paused && state.dataOrNull == null) {
return const Center(child: Text('Offline: connect to load todos'));
}
return switch (state) {
Initial() => const SizedBox.shrink(),
Loading() => const CircularProgressIndicator(),
Failure(:final error) => ErrorScreen(error),
Success(:final data) => TodoList(
todos: data,
isRefreshing: fetchStatus == FetchStatus.fetching,
isStale: fetchStatus == FetchStatus.paused,
),
};
},
),
floatingActionButton: QoraMutationBuilder<void, String, void>(
mutator: (title) => api.createTodo(title),
options: MutationOptions(
offlineQueue: true,
optimisticResponse: (title) => null,
onSuccess: (_, __, ___) async =>
context.qora.invalidate(const ['todos']),
),
builder: (context, state, mutate) {
final isOptimistic = state is MutationSuccess && state.isOptimistic;
return FloatingActionButton.extended(
onPressed: state.isPending ? null : () => mutate('New Todo'),
icon: isOptimistic
? const Icon(Icons.schedule)
: const Icon(Icons.add),
label: Text(
isOptimistic ? 'Queued offline' : 'Add Todo',
),
);
},
),
);
}
}
Next Steps
- NetworkStatusBuilder / Indicator: Widgets for connectivity UI
- QoraBuilder: Full
FetchStatusbuilder API - QoraMutationBuilder: Offline queue and
isOptimistic - QoraOptions:
networkModefield reference - QoraClientConfig:
reconnectStrategyfield reference
Persistence
Persist query results to disk and restore them on startup: giving your app an instant data layer that works fully offline.
Infinite Queries
Implement forward and bi-directional pagination with InfiniteQueryObserver: load more on scroll, pull-to-refresh, windowed paging, and cursor-based APIs.