flutter
/

BLoC Offline Support: Building Offline-First Apps with Hydrated Bloc & Queue

Last Sync: Today

On this page

11
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Offline Support: Building Offline-First Apps with Hydrated Bloc & Queue

What is BLoC Offline Support?

Offline support in BLoC refers to the ability to persist your app’s state and queue user actions (like adding, editing, or deleting data) when the device is offline. Once connectivity is restored, the queued actions are automatically synchronized with your backend. This creates a seamless user experience regardless of network availability.

Why Use Offline Support?

  • Better User Experience – Users can continue using the app even without internet.
  • Data Integrity – Changes are stored locally and synced later, preventing data loss.
  • Performance – Reduces unnecessary network calls by serving cached data first.
  • Reliability – Handles network interruptions gracefully.
  • Offline‑First Architecture – Builds apps that work everywhere, even in low‑connectivity areas.

Setting Up Hydrated Bloc

hydrated_bloc is an extension of flutter_bloc that automatically persists your state to disk. Add the required dependencies:

YAMLRead-only
1
dependencies:
  flutter_bloc: ^8.1.5
  hydrated_bloc: ^9.1.5
  path_provider: ^2.1.3
  equatable: ^2.0.5
  connectivity_plus: ^5.0.0

Initialize HydratedBloc before running the app. You need to provide a storage directory, usually from path_provider.

DARTRead-only
1
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final storage = await HydratedStorage.build(
    storageDirectory: await getApplicationDocumentsDirectory(),
  );
  HydratedBloc.storage = storage;
  runApp(MyApp());
}

Making a Bloc Hydrated

To make a BLoC persist its state, extend HydratedBloc instead of Bloc and override fromJson and toJson to serialize your state.

DARTRead-only
1
class CounterBloc extends HydratedBloc<CounterEvent, int> {
  CounterBloc() : super(0);

  @override
  Stream<int> mapEventToState(CounterEvent event) async* {
    if (event is Increment) {
      yield state + 1;
    } else if (event is Decrement) {
      yield state - 1;
    }
  }

  @override
  int fromJson(Map<String, dynamic> json) => json['value'] as int;

  @override
  Map<String, dynamic> toJson(int state) => {'value': state};
}

Handling Complex State

For more complex states, make sure all fields are serializable. Use equatable to simplify comparisons and serialization.

DARTRead-only
1
@immutable
class TodoState extends Equatable {
  final List<Todo> todos;
  final bool isLoading;
  final String? error;

  const TodoState({required this.todos, this.isLoading = false, this.error});

  TodoState copyWith({List<Todo>? todos, bool? isLoading, String? error}) {
    return TodoState(
      todos: todos ?? this.todos,
      isLoading: isLoading ?? this.isLoading,
      error: error ?? this.error,
    );
  }

  @override
  List<Object?> get props => [todos, isLoading, error];

  Map<String, dynamic> toJson() => {
    'todos': todos.map((t) => t.toJson()).toList(),
    'isLoading': isLoading,
    'error': error,
  };

  factory TodoState.fromJson(Map<String, dynamic> json) {
    return TodoState(
      todos: (json['todos'] as List).map((t) => Todo.fromJson(t)).toList(),
      isLoading: json['isLoading'] ?? false,
      error: json['error'],
    );
  }
}

class TodoBloc extends HydratedBloc<TodoEvent, TodoState> {
  TodoBloc() : super(const TodoState(todos: []));

  @override
  TodoState fromJson(Map<String, dynamic> json) => TodoState.fromJson(json);

  @override
  Map<String, dynamic> toJson(TodoState state) => state.toJson();
}

Offline Queue Pattern

State persistence handles read operations. For mutations (POST, PUT, DELETE), you need a queue that stores pending operations when offline and replays them when online. Below is a simple queue implementation using GetStorage (or any persistent storage).

DARTRead-only
1
class PendingOperation {
  final String type; // e.g., 'ADD_TODO', 'DELETE_TODO'
  final Map<String, dynamic> data;
  final DateTime timestamp;

  PendingOperation({required this.type, required this.data, required this.timestamp});

  Map<String, dynamic> toJson() => {
    'type': type,
    'data': data,
    'timestamp': timestamp.toIso8601String(),
  };

  factory PendingOperation.fromJson(Map<String, dynamic> json) => PendingOperation(
    type: json['type'],
    data: json['data'],
    timestamp: DateTime.parse(json['timestamp']),
  );
}

class OfflineQueue {
  final List<PendingOperation> _queue = [];
  final storage = GetStorage(); // Or use Hive/SQLite

  OfflineQueue() {
    _load();
  }

  void add(PendingOperation operation) {
    _queue.add(operation);
    _save();
  }

  void remove(int index) {
    _queue.removeAt(index);
    _save();
  }

  List<PendingOperation> get operations => List.unmodifiable(_queue);

  void _save() {
    storage.write('offline_queue', _queue.map((op) => op.toJson()).toList());
  }

  void _load() {
    final data = storage.read<List>('offline_queue');
    if (data != null) {
      _queue.addAll(data.map((e) => PendingOperation.fromJson(e)));
    }
  }
}

Syncing the Queue

Use a sync service that processes the queue when connectivity returns. It can be triggered by a connectivity listener.

DARTRead-only
1
class SyncService {
  final OfflineQueue queue;
  final ApiClient api;

  SyncService(this.queue, this.api);

  Future<void> sync() async {
    final operations = queue.operations;
    for (int i = 0; i < operations.length; i++) {
      final op = operations[i];
      try {
        switch (op.type) {
          case 'ADD_TODO':
            await api.addTodo(Todo.fromJson(op.data));
            break;
          case 'DELETE_TODO':
            await api.deleteTodo(op.data['id']);
            break;
        }
        queue.remove(i);
        i--; // adjust index after removal
      } catch (e) {
        // Implement retry logic (e.g., exponential backoff)
      }
    }
  }
}

class ConnectivityService {
  final Connectivity connectivity;
  final SyncService syncService;

  ConnectivityService(this.connectivity, this.syncService) {
    connectivity.onConnectivityChanged.listen((result) {
      if (result != ConnectivityResult.none) {
        syncService.sync();
      }
    });
  }
}

In your BLoC, check connectivity before performing a mutation. If offline, queue the operation and update the state optimistically.

DARTRead-only
1
class TodoBloc extends Bloc<TodoEvent, TodoState> {
  final ApiClient api;
  final OfflineQueue queue;
  final Connectivity connectivity;

  TodoBloc({required this.api, required this.queue, required this.connectivity}) : super(TodoState.initial());

  @override
  Stream<TodoState> mapEventToState(TodoEvent event) async* {
    if (event is AddTodo) {
      final isOnline = await connectivity.checkConnectivity() != ConnectivityResult.none;
      if (isOnline) {
        try {
          final todo = await api.addTodo(event.todo);
          yield state.copyWith(todos: [...state.todos, todo]);
        } catch (e) {
          yield state.copyWith(error: e.toString());
        }
      } else {
        // Offline: queue and update optimistically
        queue.add(PendingOperation(
          type: 'ADD_TODO',
          data: event.todo.toJson(),
          timestamp: DateTime.now(),
        ));
        yield state.copyWith(todos: [...state.todos, event.todo]);
      }
    }
  }
}

Conflict Resolution

When syncing, conflicts may arise (e.g., an item was deleted remotely but edited offline). Common strategies include:

  • Last Write Wins – Compare timestamps and keep the latest version.
  • Manual Merge – Show a conflict resolution UI for the user to decide.
  • Accept Server State – Overwrite local changes with the server version, then reapply queued operations if possible.
  • Version Vectors – Use vector clocks to track changes across multiple devices.

Best Practices

  • Use HydratedBloc for UI state – Keep large datasets in a separate local database.
  • Separate queue from BLoC – Offline queue management belongs in a dedicated service.
  • Implement optimistic updates – Update the UI immediately when offline, then sync later.
  • Include timestamps – Helps with ordering and conflict resolution.
  • Test offline scenarios – Simulate network changes using flutter_offline or connectivity mocks.
  • Limit queue size – Prevent unbounded growth by setting a maximum number of pending operations.

Common Mistakes

  • ❌ Storing large data in hydrated_bloc – Performance issues. ✅ Use SQLite/Hive for large collections; keep only UI state in hydrated_bloc.
  • ❌ Not handling sync errors – Failed operations get stuck. ✅ Implement retry logic with exponential backoff.
  • ❌ Forgetting to update state after sync – UI may become inconsistent. ✅ After successful sync, refresh the state from the server or re‑fetch data.
  • ❌ Missing HydratedBloc initialization – Causes runtime errors. ✅ Always set HydratedBloc.storage before runApp.
  • ❌ Ignoring race conditions during sync – Concurrent syncs can cause duplicates. ✅ Use a flag to prevent multiple syncs running simultaneously.

Conclusion

Offline support in BLoC apps is achievable by combining hydrated_bloc for state persistence with a custom queue for pending mutations. This approach gives users a seamless experience even without connectivity, and automatically syncs when the network returns. With careful conflict handling, you can build robust offline-first Flutter applications.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final storage = await HydratedStorage.build(
    storageDirectory: await getApplicationDocumentsDirectory(),
  );
  HydratedBloc.storage = storage;
  runApp(MyApp());
}

class CounterCubit extends HydratedCubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);

  @override
  int fromJson(Map<String, dynamic> json) => json['value'] as int;

  @override
  Map<String, dynamic> toJson(int state) => {'value': state};
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => CounterCubit(),
        child: CounterPage(),
      ),
    );
  }
}

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final cubit = context.watch<CounterCubit>();
    return Scaffold(
      appBar: AppBar(title: Text('Hydrated Bloc Demo')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Counter value:', style: TextStyle(fontSize: 20)),
            Text('${cubit.state}', style: TextStyle(fontSize: 40)),
            SizedBox(height: 20),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: cubit.increment,
                  child: Icon(Icons.add),
                ),
                SizedBox(width: 20),
                ElevatedButton(
                  onPressed: cubit.decrement,
                  child: Icon(Icons.remove),
                ),
              ],
            ),
            SizedBox(height: 20),
            Text('Try closing and reopening the app – the counter persists!'),
          ],
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

Which class should your BLoC extend to enable automatic state persistence?

A
Bloc
B
Cubit
C
HydratedBloc
D
PersistedBloc
Q2
of 3

What method must you override to deserialize your state in a hydrated BLoC?

A
fromJson
B
toJson
C
mapEventToState
D
onTransition
Q3
of 3

How do you handle mutations (like adding a todo) when the device is offline?

A
Show an error and do nothing
B
Queue the operation and update state optimistically
C
Ignore the user action
D
Force the app to close

Frequently Asked Questions

Can I use HydratedBloc with multiple blocs?

Yes, each bloc can be hydrated independently. HydratedBloc uses a separate storage file per bloc automatically.

Is there a limit to how much state HydratedBloc can store?

It uses Hive under the hood, which has no strict limit, but large states may impact performance. Use it for UI state, not large datasets.

How do I clear persisted state?

Use HydratedBloc.storage.clear() or delete the storage directory. For individual blocs, you can also delete the specific file from the storage directory.

Can I use SQLite instead of HydratedBloc for offline storage?

Yes, for large data you can use SQLite with a separate repository, but HydratedBloc is convenient for UI state. You can combine both.

How do I handle file uploads offline?

Queue the file metadata and store the file locally (e.g., using path_provider). When online, upload the file and update the queue.

What about authentication tokens when offline?

Store tokens securely (e.g., flutter_secure_storage) and attach them when syncing; if the token expires, handle refresh in the sync service.

Previous

bloc upload download

Next

bloc session management

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.