Skip to content

Offline Tiers Reference

Offline Capability Tiers

Each feature declares its offline tier. Most features need only Tier 0 or 1.

MOFA’s offline framework defines four tiers of offline capability. Each feature explicitly declares its tier, and the implementation complexity increases with each level.

  • No offline support
  • Shows error/loading state when network is unavailable
  • Standard behavior - no additional implementation needed
  • Highly volatile data (live polls, real-time dashboards)
  • Features that are meaningless without server connectivity
  • Admin/management features used in reliable network environments

Default behavior. No additional code required.

@riverpod
class LivePollNotifier extends _$LivePollNotifier {
@override
Stream<PollResults> build(String pollId) {
// Standard implementation - no offline handling
final repository = ref.watch(pollRepositoryProvider);
return repository.subscribeToPoll(pollId: pollId);
}
}

  • Serves last successfully fetched data from Ferry’s cache when offline
  • Shows a “stale data” or “offline” indicator to the user
  • New data fetched automatically when connectivity returns
  • Reference data (event schedule, attendee list, venue maps)
  • History data (notification history, past bookings)
  • Any feature where showing recent data is better than showing nothing

Ferry provides Tier 1 automatically via its built-in cache. Add connectivity awareness:

@riverpod
class EventScheduleNotifier extends _$EventScheduleNotifier {
@override
Stream<List<EventModel>> build() {
final repository = ref.watch(eventRepositoryProvider);
final connectivity = ref.watch(connectivityProvider);
return repository.queryList(
filter: EventFilterCriteria(conferenceId: conferenceId),
sort: EventSortCriteria(startTime: SortOptions.ascending),
);
// Ferry's cache serves stale data when offline automatically
}
}
// UI shows offline indicator based on connectivity state
Widget build(BuildContext context, WidgetRef ref) {
final isOnline = ref.watch(connectivityProvider);
final eventsAsync = ref.watch(eventScheduleNotifierProvider);
return Column(
children: [
if (!isOnline) const OfflineBanner(),
Expanded(child: eventsAsync.when(/* ... */)),
],
);
}
  • Ferry’s normalized cache (__typename:id) serves entities individually
  • fetchPolicy: FetchPolicy.CacheAndNetwork fetches from cache first, then network
  • No additional storage layer needed

  • Tier 1 capabilities (read cache fallback)
  • Mutations queued locally when offline
  • Queued mutations synced automatically when connectivity returns
  • Optimistic UI updates for queued mutations
  • User actions that should persist (mark attendance, submit feedback)
  • Forms that can be submitted later
  • Toggle actions (bookmark, like, RSVP)

Requires a caching service queue and sync coordinator:

// Offline mutation queue
@riverpod
class OfflineMutationQueue extends _$OfflineMutationQueue {
@override
List<QueuedMutation> build() => [];
Future<void> enqueue(QueuedMutation mutation) async {
// Store in local cache
await ref.read(cachingServiceProvider).store(
key: 'offline_queue',
value: [...state, mutation],
);
state = [...state, mutation];
}
Future<void> sync() async {
final connectivity = ref.read(connectivityProvider);
if (!connectivity) return;
for (final mutation in state) {
try {
await _executeMutation(mutation);
state = state.where((m) => m.id != mutation.id).toList();
} catch (e) {
// Keep in queue for retry
break; // Stop on first failure to maintain order
}
}
}
}
// Feature repository with offline support
class AttendanceRepository {
Future<void> markAttendance({
required String attendeeId,
bool optimistic = true,
}) async {
final isOnline = ref.read(connectivityProvider);
if (isOnline) {
// Direct mutation
await upsertDatasource.execute((params, optimistic));
} else {
// Queue for later
await ref.read(offlineMutationQueueProvider.notifier).enqueue(
QueuedMutation(
type: 'markAttendance',
params: {'attendeeId': attendeeId},
createdAt: DateTime.now(),
),
);
}
}
}
  • Queue persists in local cache (survives app restart)
  • Mutations replayed in order
  • Failed mutations remain in queue for retry
  • UI shows queued/pending state for offline mutations

  • Tier 2 capabilities (read cache + write queue)
  • Local database mirrors server state
  • Bidirectional sync with conflict resolution
  • App functions fully offline for extended periods
  • Field work applications
  • Offline-first experiences
  • Scenarios requiring extended offline operation

Requires local database, sync engine, and conflict resolution:

// Sync engine (simplified)
@riverpod
class SyncEngine extends _$SyncEngine {
@override
SyncState build() => SyncState.idle;
Future<void> fullSync() async {
state = SyncState.syncing;
// 1. Push local changes
await _pushLocalChanges();
// 2. Pull remote changes
await _pullRemoteChanges();
// 3. Resolve conflicts
await _resolveConflicts();
state = SyncState.idle;
}
}
// Conflict resolution strategies
enum ConflictStrategy {
serverWins, // Server data always takes precedence
clientWins, // Client data always takes precedence
latestWins, // Most recent timestamp wins
manual, // User resolves conflict manually
}
  • Requires version tracking (timestamps or vector clocks)
  • Conflict resolution strategy must be defined per entity
  • Local database schema must match domain models
  • Consider data volume and sync frequency

FactorTier 0Tier 1Tier 2Tier 3
Read offlineNoYes (cached)Yes (cached)Yes (local DB)
Write offlineNoNoYes (queued)Yes (synced)
Implementation effortNoneMinimalMediumHigh
Storage requirementNoneFerry cacheCache + queueLocal database
Conflict handlingN/AN/ARetry on syncFull resolution
Typical featuresLive dataReference dataUser actionsOffline-first

The caching service provides the foundation for Tier 1-3:

  • Secure Cache: Tokens and credentials (available offline)
  • Simple Cache: Preferences and settings (Tier 1+)
  • Complex Cache: Offline queue and sync metadata (Tier 2+)

See ADR-007: Offline Tiers for the full decision record.