In today’s interconnected world, users expect mobile applications to be available and responsive at all times, regardless of their network connectivity. However, relying solely on a stable internet connection can pose challenges, especially in areas with poor connectivity or when users are traveling. This is where the “Offline First” approach comes into play.
The “Offline First” approach prioritizes the functionality and user experience of an application even when there is no internet connection available. By designing applications to work seamlessly offline and syncing data with servers when a connection is available, developers can enhance the reliability and accessibility of their mobile apps.
In this blog post, we’ll explore the concept of “Offline First” and its significance in mobile app development. We’ll discuss the benefits of adopting this approach and provide practical strategies for implementing it in Flutter, Google’s UI toolkit for building natively compiled applications for mobile, web, and desktop from a single codebase.
Let’s dive in and discover how to create mobile apps that excel in both online and offline scenarios using Flutter’s powerful features and best practices.
What is the Offline First Approach?
The “Offline First” approach is a development strategy that prioritizes the offline functionality of an application. Unlike traditional approaches where applications rely heavily on continuous internet connectivity, an “Offline First” approach ensures that the application remains functional even when offline, with seamless synchronization of data when an internet connection is available.
At its core, the Offline First approach aims to provide users with a consistent and reliable experience regardless of their network status. This means users can access key features, perform actions, and view content even in environments with limited or no connectivity.
By designing applications with offline capabilities from the outset, developers can address common challenges such as network latency, intermittent connectivity, and data usage limitations. This not only improves the user experience but also reduces reliance on a stable internet connection, making the application more resilient and accessible to a wider audience.
In the context of mobile app development, implementing an Offline First approach involves several key strategies, including local data storage, background data synchronization, and robust error handling. These strategies ensure that users can interact with the app seamlessly, regardless of their network conditions.
Benefits of Offline First Mobile App Approach
- Improved User Experience: By enabling offline functionality, users can access and interact with the app’s core features even when they are not connected to the internet. This ensures a consistent and seamless user experience, leading to higher user satisfaction and engagement.
- Reduced Dependency on Network Connectivity: With an Offline First approach, mobile apps are less reliant on a stable internet connection. Users can continue using the app in areas with poor or no connectivity, such as underground transportation or remote rural areas, without experiencing interruptions.
- Optimized Performance: Offline First apps often perform better than their online-only counterparts due to reduced network requests and faster local data access. This results in faster load times, smoother navigation, and overall better performance, enhancing the app’s usability and appeal.
- Cost Savings for Users: Offline First apps can help users save on data costs by minimizing the need for continuous data usage. Users can download content and perform actions while connected to Wi-Fi, reducing their reliance on expensive mobile data plans.
- Increased Accessibility: By accommodating users with limited or intermittent internet access, Offline First apps become more accessible to a broader audience. This includes users in developing regions, travelers, or those with limited data plans, who may otherwise struggle to use online-only applications reliably.
- Data Security and Privacy: Storing data locally on the device enhances security and privacy, as sensitive information remains within the user’s control. Offline First apps often employ encryption and other security measures to protect user data, reducing the risk of data breaches or unauthorized access.
- Seamless Synchronization: Offline First apps seamlessly synchronize data with servers when an internet connection is available, ensuring that users’ data remains up-to-date across devices. This enables users to switch between online and offline modes without losing their progress or data.
By leveraging the Offline First approach, developers can create mobile apps that are more resilient, user-friendly, and accessible, catering to the diverse needs of modern users in an increasingly connected world.
Strategies for Implementing Offline First in Flutter
- Local Data Storage:
- Utilize local storage solutions like SQLite databases or key-value stores (such as shared_preferences) to store essential app data locally on the device.
- For structured data storage, use Hive, a lightweight and fast NoSQL database solution for Flutter, providing efficient local storage capabilities.
- Background Data Synchronization:
- Implement background data synchronization to update local data with the server when the device is online.
- Use packages like dio for making HTTP requests and implement background tasks with the help of plugins like workmanager or background_fetch to synchronize data periodically or in response to specific events.
- Offline Capabilities for UI:
- Design UI components to gracefully handle offline scenarios, such as displaying cached data, disabling certain features, or providing informative messages to users.
- Utilize Flutter’s state management solutions like Provider or Riverpod to manage UI state and conditionally render UI elements based on the app’s online/offline status.
- Data Caching and Prefetching:
- Cache frequently accessed data locally to reduce the need for network requests.
- Implement prefetching mechanisms to proactively fetch data in the background when the device is online, ensuring a smooth user experience even in offline mode.
- Error Handling and Recovery:
- Implement robust error handling mechanisms to gracefully handle network errors, server timeouts, and other connectivity issues.
- Provide feedback to users about the status of data synchronization and guide them on how to resolve any connectivity-related issues.
By implementing these strategies, developers can create Flutter apps that offer robust offline capabilities while maintaining a smooth and reliable user experience across different network conditions.
Practical Example: Building an Offline First ToDo App with Flutter
Introduction:
In this practical example, we’ll build an Offline-First ToDo app using Flutter. The app will allow users to create, read, update, and delete ToDo items both online and offline. We’ll cover several modules:
- Data Models: Define the models for ToDo items and remote operations.
- Local Storage Service: Implement local storage operations using Hive.
- Remote Service: Implement remote CRUD operations using Dio.
- Repository Layer: Define repositories for managing data operations.
- BLoC Layer: Implement BLoCs for business logic and state management.
- UI Layer: Develop the UI components using Flutter.
Project Setup:
- Create a new Flutter project.
- Add the required packages to your
pubspec.yaml
file:
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.5
hive: ^2.0.4
hive_flutter: ^1.1.0
rxdart: ^0.27.2
dartz: ^0.10.0
bloc: ^7.0.0
connectivity_plus: ^1.0.6
dio: ^4.0.0
internet_connection_checker: ^0.0.1
Create the necessary folders in your project directory according to the provided folder structure. This is the folder structure I use for simplicity, use the one you like and feel more comfortable with.
- lib
- presentation
- bloc
- views
- widgets
- repositories
- services
- local_storage_service
- remote_service
- models
- core
ToDo App Features:
- Ability to add, edit, and delete ToDo items.
- List of ToDo items displayed in the app.
- Offline storage of ToDo items using Hive.
- Background synchronization with a remote server using BLoC and Dio.
Note: Basic Setup
For this practical example, we won’t cover basic setup tasks such as creating the Flutter project, initializing dependencies, and other initial configurations. Instead, we’ll focus on building the core functionality of an Offline-First app using Flutter. If you want to see the full project, it will be available on my GitHub, with a link at the end of the blog.
The app will include modules for data models, local storage service, remote service, repository layer, BLoC layer, and UI layer. Each module will be responsible for a specific aspect of the app’s functionality, making the codebase organized and easy to understand.
If you’re new to Flutter or need assistance with project setup, you can refer to official documentation and tutorials available online. Once you have your project set up and dependencies installed, you can follow along with the practical example to implement the Offline-First ToDo app.
Let’s start with the first module:
Data Models
We will have the ToDo model and the Remote Operation model. We won’t go into detail of the ToDo model, instead we will focus on the Remote Operation model. This model will help us store al the operations we can’t do in the server while we don’t have an internet connection.
class RemoteOperation {
final String endpoint;
final HTTPMethod method;
final Map<String, dynamic> body;
RemoteOperation({
required this.endpoint,
required this.method,
required this.body,
});
RemoteOperation copyWith({
String? endpoint,
HTTPMethod? method,
Map<String, dynamic>? body,
}) {
return RemoteOperation(
endpoint: endpoint ?? this.endpoint,
method: method ?? this.method,
body: body ?? this.body,
);
}
factory RemoteOperation.fromJson(Map<String, dynamic> json) {
return RemoteOperation(
endpoint: json['endpoint'] as String,
method: HTTPMethod.fromJson(json['method']),
body: json['body'] as Map<String, dynamic>,
);
}
Map<String, dynamic> toJson() {
return {
'endpoint': endpoint,
'method': HTTPMethod.toJson(method),
'body': body,
};
}
}
enum HTTPMethod {
get,
post,
put,
delete;
static String toJson(HTTPMethod method) {
switch (method) {
case HTTPMethod.get:
return 'GET';
case HTTPMethod.post:
return 'POST';
case HTTPMethod.put:
return 'PUT';
case HTTPMethod.delete:
return 'DELETE';
}
}
static HTTPMethod fromJson(String method) {
switch (method) {
case 'GET':
return HTTPMethod.get;
case 'POST':
return HTTPMethod.post;
case 'PUT':
return HTTPMethod.put;
case 'DELETE':
return HTTPMethod.delete;
default:
throw Exception('Invalid HTTPMethod');
}
}
}
The RemoteOperation model represents an operation to be performed on a remote server. It includes the following properties:
- endpoint: The endpoint or URL where the operation should be performed.
- method: The HTTP method to be used for the operation (GET, POST, PUT, DELETE).
- body: The payload or data to be sent with the operation.
The model provides methods for JSON serialization and deserialization, similar to the ToDo model. Additionally, it includes an enumeration HTTPMethod to represent the HTTP methods, with helper methods to convert between String and HTTPMethod values.
Local Storage Service
The LocalStorageService class manages local data storage using Hive, a lightweight and fast NoSQL database for Flutter. It includes methods for managing ToDo items and remote operations, providing functionality for CRUD operations and cache management.
import 'dart:convert';
import 'package:rxdart/rxdart.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:todo_offline_first_tutorial/lib.dart';
class LocalStorageService {
const LocalStorageService({
required Box<String> hiveBox,
}) : _hiveBox = hiveBox;
final Box<String> _hiveBox;
Future<void> addCacheRemoteOperation(RemoteOperation remoteOperation) async {
try {
final remoteOperations = getCacheRemoteOperations()..add(remoteOperation);
return await setCacheRemoteOperations(remoteOperations);
} catch (e) {
throw CacheException();
}
}
List<RemoteOperation> getCacheRemoteOperations() {
try {
final jsonStr = _hiveBox.get('CachedRemoteOperations');
if (jsonStr == null) return [];
final result = jsonDecode(jsonStr) as List<dynamic>;
return result
.map((json) => RemoteOperation.fromJson(json as Map<String, dynamic>))
.toList();
} catch (e) {
throw CacheException();
}
}
Future<void> removeCacheRemoteOperations() async {
try {
return await _hiveBox.delete('CachedRemoteOperations');
} catch (e) {
throw CacheException();
}
}
Future<void> removeCacheRemoteOperation(RemoteOperation remoteOperation) {
try {
final remoteOperations = getCacheRemoteOperations();
final index = remoteOperations.indexWhere(
(cacheRemoteOperation) => cacheRemoteOperation == remoteOperation);
remoteOperations.removeAt(index);
return setCacheRemoteOperations(remoteOperations);
} catch (e) {
throw CacheException();
}
}
Future<void> setCacheRemoteOperations(
List<RemoteOperation> remoteOperations) async {
try {
await _hiveBox.put(
'CachedRemoteOperations',
jsonEncode(remoteOperations),
);
} catch (e) {
throw CacheException();
}
}
}
Remote Operation Operations:
- remoteOperationStream: Returns a stream of remote operations from the Hive box. It watches for changes in the CachedRemoteOperations key and maps the JSON string to a list of RemoteOperation objects.
- addCacheRemoteOperation: Adds a new remote operation to the cache list.
- getCacheRemoteOperations: Retrieves the list of remote operations from the cache.
- removeCacheRemoteOperations: Removes all remote operations from the cache.
- removeCacheRemoteOperation: Removes a specific remote operation from the cache list.
- setCacheRemoteOperations: Sets the remote operations list in the cache by encoding it to JSON and storing it in the Hive box.
While ToDo cache operations are not included in this code section, they are available on the full project at Github. This operations include adding, editing and deleting ToDo items. We also use a stream that goes all the way to the UI for automatic updates.
Remote Service
The RemoteService class handles communication with the remote server using Dio, a powerful HTTP client for Dart. It provides methods for performing CRUD operations on ToDo items and synchronizing remote operations.
import 'package:todo_offline_first_tutorial/lib.dart';
import 'package:dio/dio.dart';
class RemoteService {
const RemoteService(Dio dio) : _dio = dio;
final Dio _dio;
// ToDo operations go here
// Full code available at my GitHub
Future<void> synchronize(RemoteOperation remoteOperation) async {
Response<dynamic> response;
try {
final methods = {
HTTPMethod.get: _dio.get<Map<String, dynamic>>,
HTTPMethod.post: _dio.post<Map<String, dynamic>>,
HTTPMethod.put: _dio.put<Map<String, dynamic>>,
HTTPMethod.delete: _dio.delete<Map<String, dynamic>>,
};
var method = methods[remoteOperation.method];
if (method != null) {
response = await method(
remoteOperation.endpoint,
data: remoteOperation.body,
);
} else {
throw UnknownNetworkException();
}
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.sendTimeout ||
e.type == DioExceptionType.receiveTimeout) {
throw TimeoutException();
} else if (e.type == DioExceptionType.connectionError) {
throw ConnectionErrorException();
} else {
throw UnknownNetworkException();
}
} catch (e) {
throw UnknownNetworkException();
}
if (response.statusCode != 200) {
parseErrorCodeToException(response.statusCode ?? 0);
}
}
}
- ToDo Operations:
- createTodo: Sends a POST request to create a new ToDo item on the server.
- updateTodo: Sends a PUT request to update an existing ToDo item on the server.
- deleteTodo: Sends a DELETE request to delete a ToDo item on the server by its ID.
- getTodos: Sends a GET request to fetch all ToDo items from the server.
- Synchronization:
- synchronize: Executes a remote operation by sending an appropriate HTTP request based on the provided RemoteOperation. It handles GET, POST, PUT, and DELETE methods. If any exception occurs during the request, it throws the corresponding exception (TimeoutException, ConnectionErrorException, or UnknownNetworkException). It also checks the response status code and throws an exception if it’s not 200.
Repositories
DataSynchronizationRepository:
The DataSynchronizationRepository is responsible for handling data synchronization between the local cache and the remote server.
import 'package:todo_offline_first_tutorial/lib.dart';
import 'package:dartz/dartz.dart';
class DataSynchronizationRepository {
const DataSynchronizationRepository({
required this.localStorageService,
required this.remoteService,
});
final LocalStorageService localStorageService;
final RemoteService remoteService;
Future<bool> doNeedToSynchronize() async {
try {
final remoteOperations = localStorageService.getCacheRemoteOperations();
return remoteOperations.isNotEmpty;
} catch (e) {
return false;
}
}
Future<Either<Failure, Unit>> synchronizeData() async {
try {
final remoteOperations = localStorageService.getCacheRemoteOperations();
for (final remoteOperation in remoteOperations) {
await remoteService.synchronize(remoteOperation);
await localStorageService.removeCacheRemoteOperation(remoteOperation);
}
return const Right(unit);
} catch (e) {
return Left(ParseYourErrorHere());
}
}
}
Methods:
- doNeedToSynchronize: Checks if there are any pending remote operations in the local cache. Returns true if there are operations pending synchronization, otherwise false.
- synchronizeData: Synchronizes data by fetching pending remote operations from the local cache, executing them using the RemoteService, and then removing them from the local cache once they are synchronized. It returns Right(unit) on successful synchronization and Left with a failure object if an error occurs
We also have a ToDo Repository that handles all the logic for creating, editing and deleting ToDo’s. A stream that comes from the local service comunicates the cached ToDo’s with the BLoC.
- Methods:
- get todoList: Returns a stream of ToDo items from the local storage service.
- getTodos: Retrieves ToDo items from the remote server if there is an internet connection, storing them in the local cache. It returns Right(unit) on success or Left with a failure object on error.
- createToDo: Adds a new ToDo item locally and then attempts to create it on the remote server. If there is no internet connection, it stores the operation in the local cache for later synchronization. Returns Right(unit) on success or Left with a failure object on error.
- deleteTodo: Deletes a ToDo item locally and tries to delete it from the remote server. If there is no internet connection, it stores the operation in the local cache for later synchronization. Returns Right(unit) on success or Left with a failure object on error.
- updateToDo: Updates a ToDo item locally and then attempts to update it on the remote server. If there is no internet connection, it stores the operation in the local cache for later synchronization. Returns Right(unit) on success or Left with a failure object on error.
BLoCs
Connection Monitoring BLoC
The connection monitoring BLoC is responsible for managing and monitoring the device’s internet connection and triggering data synchronization when necessary.
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:internet_connection_checker/internet_connection_checker.dart';
import 'package:todo_offline_first_tutorial/lib.dart';
part 'connection_monitoring_event.dart';
part 'connection_monitoring_state.dart';
class ConnectionMonitoringBloc
extends Bloc<ConnectionMonitoringEvent, ConnectionMonitoringState> {
ConnectionMonitoringBloc({
required DataSynchronizationRepository dataSynchronizationRepository,
required Connectivity connectivity,
required InternetConnectionChecker internetConnectionChecker,
}) : _dataSynchronizationRepository = dataSynchronizationRepository,
_connectivity = connectivity,
_internetConnectionChecker = internetConnectionChecker,
super(ConnectionMonitoringInitial()) {
on<MonitorConnectionChanges>(_onMonitorConnectionChanges);
on<CheckConnection>(_onCheckConnection);
}
final DataSynchronizationRepository _dataSynchronizationRepository;
final Connectivity _connectivity;
final InternetConnectionChecker _internetConnectionChecker;
FutureOr<void> _onMonitorConnectionChanges(
MonitorConnectionChanges event,
Emitter<ConnectionMonitoringState> emit,
) async {
final connectionStream = _connectivity.onConnectivityChanged;
await emit.onEach(
connectionStream,
onData: (value) {
add(CheckConnection());
},
);
}
FutureOr<void> _onCheckConnection(
CheckConnection event,
Emitter<ConnectionMonitoringState> emit,
) async {
final hasInternetConnection =
await _internetConnectionChecker.hasConnection;
if (hasInternetConnection) {
final needToSynchronize =
await _dataSynchronizationRepository.doNeedToSynchronize();
if (needToSynchronize) {
emit(ConnectionMonitoringSynchronizing());
final result = await _dataSynchronizationRepository.synchronizeData();
result.fold(
(failure) {
return emit(ConnectionMonitoringSynchronizationFailed());
},
(_) {
return emit(ConnectionMonitoringSynchronized());
},
);
} else {
return emit(ConnectionMonitoringSynchronized());
}
} else {
return emit(
ConnectionMonitoringNoConnection(),
);
}
}
}
part of 'connection_monitoring_bloc.dart';
@immutable
sealed class ConnectionMonitoringEvent {}
final class MonitorConnectionChanges extends ConnectionMonitoringEvent {}
final class CheckConnection extends ConnectionMonitoringEvent {}
part of 'connection_monitoring_bloc.dart';
@immutable
sealed class ConnectionMonitoringState {}
final class ConnectionMonitoringInitial extends ConnectionMonitoringState {}
final class ConnectionMonitoringSynchronizing
extends ConnectionMonitoringState {}
final class ConnectionMonitoringSynchronized
extends ConnectionMonitoringState {}
final class ConnectionMonitoringSynchronizationFailed
extends ConnectionMonitoringState {}
final class ConnectionMonitoringNoConnection
extends ConnectionMonitoringState {}
- Events:
- MonitorConnectionChanges: Indicates a request to monitor changes in the device’s connectivity status.
- CheckConnection: Indicates a request to check the current internet connection status.
- States:
- ConnectionMonitoringInitial: Initial state before any monitoring or synchronization occurs.
- ConnectionMonitoringSynchronizing: State indicating that data synchronization is in progress.
- ConnectionMonitoringSynchronized: State indicating that data synchronization has been successfully completed.
- ConnectionMonitoringSynchronizationFailed: State indicating that data synchronization has failed.
- ConnectionMonitoringNoConnection: State indicating that there is no internet connection available.
- Methods:
- _onMonitorConnectionChanges: Listens for changes in the device’s connectivity status and triggers a CheckConnection event.
- _onCheckConnection: Checks the current internet connection status and performs data synchronization if there is an internet connection available. It emits appropriate states based on the synchronization process.
This BLoC encapsulates the logic for monitoring the device’s connectivity, checking for internet availability, and initiating data synchronization accordingly.
We also have a ToDo’s BLoC for managing all things related to creating, updating and deleting items. This BLoC manages the todo state and handles todo-related events by interacting with the ToDoRepository.
UI Layer
In the UI Layer we will have our BLoC provider adding the event for monitoring the connection changes.
MultiBlocProvider(
providers: [
// Bloc for monitoring connection changes
BlocProvider(
create: (context) => ConnectionMonitoringBloc(
dataSynchronizationRepository: sl(),
connectivity: sl(),
internetConnectionChecker: sl(),
)..add(
MonitorConnectionChanges(),
),
),
// Bloc for managing todo operations
BlocProvider(
create: (context) => TodoBloc(sl())
..add(LoadToDos()) // Load todos from local storage
..add(ListenToToDosInCache()), // Listen to changes in cached todos
),
],
)
// Rest of code
In our main ToDo list screen we will have a BLoC listener that triggers Snackbars for displaying data synchronization and internet connection information.
BlocListener<ConnectionMonitoringBloc, ConnectionMonitoringState>(
listener: (context, state) {
return switch (state) {
ConnectionMonitoringSynchronized() =>
context.read<TodoBloc>().add(LoadToDos()),
ConnectionMonitoringNoConnection() =>
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'No internet connection',
style: TextStyle(color: Colors.red),
),
backgroundColor: Colors.grey.shade800,
),
),
ConnectionMonitoringSynchronizing() =>
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Synchronizing data',
style: TextStyle(color: Colors.yellow)),
backgroundColor: Colors.grey.shade800,
),
),
ConnectionMonitoringSynchronizationFailed() =>
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Failed to synchronize data',
style: TextStyle(color: Colors.red)),
backgroundColor: Colors.grey.shade800,
),
),
};
},
// OTHER UI CODE
);
Conclusion
In this blog, we’ve delved into the concept of offline-first mobile app development, exploring its importance, benefits, and implementation strategies. Here’s a summary of key points covered:
- Understanding Offline-First:
- Offline-first development prioritizes creating apps that remain functional even without an internet connection.
- It enhances user experience by reducing reliance on network connectivity and providing seamless interactions.
- Benefits of Offline-First:
- Improved user experience: Users can access and interact with the app regardless of network availability, reducing frustration and increasing engagement.
- Increased reliability: By storing data locally and synchronizing with a remote server when possible, offline-first apps are less prone to data loss and inconsistencies.
- Cost savings: Reduced network usage can lead to lower data costs for users and lower server costs for app developers.
- Implementation Strategies:
- Local Data Storage: Utilize local databases like SQLite, Hive, or shared preferences to store data locally on the device.
- Data Synchronization: Implement mechanisms to synchronize local data with a remote server when an internet connection is available.
- Connection Monitoring: Monitor network connectivity and adapt the app’s behavior accordingly, providing feedback to users about their connection status.
- Key Technologies:
- Flutter: A cross-platform framework for building mobile apps, which offers rich features and performance suitable for offline-first development.
- Dio: A powerful HTTP client for Flutter, used for making network requests and interacting with remote servers.
- Hive: A lightweight and fast NoSQL database for Flutter, ideal for local data storage in offline-first apps.
- Bloc Pattern: A state management pattern for Flutter that helps in managing complex state and business logic, making it suitable for offline-first apps.
- Best Practices:
- Modular Architecture: Divide the app into layers (data, repository, blocs, UI) to maintain separation of concerns and facilitate scalability and maintainability.
- Dependency Injection: Use dependency injection to provide dependencies to different parts of the app, making the codebase more testable and maintainable.
- Error Handling: Implement robust error handling mechanisms to gracefully handle network failures and other errors that may occur in offline scenarios.
By adopting the offline-first approach and following best practices and implementation strategies outlined in this blog, developers can create mobile apps that provide a reliable and consistent user experience, even in challenging network conditions.