Why Offline Data Sync Is a Must‑Have for Modern Flutter Apps
When you build a cross‑platform app with Flutter, you’re already betting on a single codebase that runs on iOS, Android, web, and desktop. The next logical step is to make that experience reliable even when the device loses its internet connection. Users expect their data to be saved instantly and to appear the next time they go online—whether they’re filling out a sales order in a remote warehouse or updating inventory from a coffee shop’s tablet.
Implementing offline data sync does more than just improve user satisfaction. It reduces the number of failed API calls, lowers server load, and protects critical business information from being lost during intermittent connectivity. For small and medium businesses that rely on real‑time data, a robust sync layer can be the difference between a smooth operation and a costly outage.
Core Concepts Behind Offline Data Sync in Flutter
1. Local Persistence Layer
The first piece of the puzzle is a reliable local database. In Flutter you have several proven options:
- sqflite – a SQLite wrapper that offers full SQL support and is ideal when you need complex queries.
- Hive – a lightweight, NoSQL key‑value store that works well for simple objects and offers blazing‑fast read/write.
- Drift (formerly Moor) – combines the power of SQLite with a reactive API, making it easy to listen to changes.
Choose the store that matches the complexity of your data model. For most business apps, sqflite provides the flexibility to handle relational data such as orders, customers, and inventory.
2. Change Tracking
Every time a user creates, updates, or deletes a record while offline, you must flag that record as “pending sync.” The most common pattern is to add a syncStatus column (e.g., pending, synced, error) and a lastModified timestamp. This metadata lets the sync engine know exactly which rows need to be pushed to the server.
3. Connectivity Detection
Flutter’s connectivity_plus package lets you listen for network changes. Pair it with a background task library such as workmanager (Android) or background_fetch (iOS) to trigger sync attempts even when the app is not in the foreground.
4. Conflict Resolution Strategy
When two devices edit the same record offline, the server will receive competing updates. Decide on a strategy early:
- Last write wins – simple but may discard valuable changes.
- Merge on server – requires server‑side logic to combine fields intelligently.
- Prompt the user – best for critical data, though it adds UI complexity.
Most SMEs start with “last write wins” and evolve to more sophisticated merging as the product matures.
Step‑by‑Step Guide to Implement Offline Sync in a Flutter App
Step 1: Set Up the Local Database
Install sqflite and create a helper class:
final db = await openDatabase(
join(await getDatabasesPath(), 'business.db'),
onCreate: (db, version) {
return db.execute('''
CREATE TABLE orders(
id TEXT PRIMARY KEY,
customer TEXT,
amount REAL,
syncStatus TEXT,
lastModified INTEGER
)
''');
},
version: 1,
);
This table includes syncStatus and lastModified for tracking.
Step 2: Wrap CRUD Operations with Sync Flags
Whenever you insert or update a record, set syncStatus = 'pending' and store the current epoch time:
Future addOrder(Order order) async {
await db.insert(
'orders',
order.toMap()
..['syncStatus'] = 'pending'
..['lastModified'] = DateTime.now().millisecondsSinceEpoch,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Step 3: Detect Connectivity Changes
Use connectivity_plus to listen for a transition to Wi‑Fi or mobile data:
final connectivity = Connectivity();
connectivity.onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
SyncService.instance.startSync();
}
});
Step 4: Implement the Sync Service
The sync service performs three main actions:
- Pull pending rows from the local DB.
- POST/PUT each row to the remote API.
- Update
syncStatusbased on the server response.
Here’s a simplified version:
class SyncService {
static final instance = SyncService._();
SyncService._();
Future startSync() async {
final pending = await db.query('orders',
where: 'syncStatus = ?', whereArgs: ['pending']);
for (var row in pending) {
try {
final response = await http.post(
Uri.parse('https://api.example.com/orders'),
body: jsonEncode(row),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
await db.update('orders',
{'syncStatus': 'synced'},
where: 'id = ?', whereArgs: [row['id']]);
} else {
await db.update('orders',
{'syncStatus': 'error'},
where: 'id = ?', whereArgs: [row['id']]);
}
} catch (_) {
// Network hiccup – keep status as pending for next attempt
}
}
}
}
Wrap the network call in a try/catch block so that transient failures don’t mark records as errors.
Step 5: Run Sync in the Background
For Android, add workmanager to schedule a periodic task:
Workmanager().initialize(
callbackDispatcher,
isInDebugMode: false,
);
Workmanager().registerPeriodicTask(
"syncTask",
"syncOrders",
frequency: const Duration(minutes: 15),
);
The callbackDispatcher simply calls SyncService.instance.startSync(). iOS uses background_fetch with a similar setup. This ensures that data is synced even when the user closes the app.
Testing and Monitoring Your Sync Logic
Before releasing, simulate offline conditions:
- Turn off Wi‑Fi and cellular on a test device, create several orders, then restore connectivity.
- Verify that the
syncStatuscolumn changes frompendingtosyncedwithout user intervention. - Introduce a version conflict on the server to see how your chosen resolution strategy behaves.
Logging is crucial. Use a lightweight logger like logger to capture sync attempts, successes, and failures. You can later pipe those logs to a remote monitoring service (e.g., Sentry) for proactive alerts.
When to Call in an Expert Partner
While the steps above cover a typical implementation, enterprise‑grade apps often need:
- End‑to‑end encryption of