TL;DR: This guide demonstrates how to architect an offline-first Flutter application using
sqfliteto handle spotty network conditions in enterprise field apps. It details how to implement an event-sourcingsync_queuetable to track local data mutations and explains why client-generated UUIDs are essential to prevent primary key collisions during Node.js backend synchronization.
⚡ Key Takeaways
- Restructure your data flow to
UI -> Local DB -> Sync Queue -> APIso the local device acts as the primary source of truth and users never wait on network requests. - Use the
sqflitepackage'sdb.batch()method to efficiently create both your business entity tables and your sync queue tables during database initialization. - Implement an event-sourcing
sync_queuetable that tracks specific actions (INSERT,UPDATE,DELETE) and JSON payloads to give the backend granular context for conflict resolution. - Generate universally unique identifiers (UUIDs) on the client for all business entities instead of using auto-incrementing integers to prevent primary key collisions when multiple offline devices sync.
- Wrap local UI state updates and
sync_queuerecord insertions inside a single SQLite transaction (db.transaction) to guarantee data consistency before background syncing occurs.
Field workers evaluating construction sites, delivering packages to concrete basements, or repairing remote cellular towers all share one operational reality: spotty, unreliable network coverage.
Standard API-driven mobile apps fail miserably in these environments. When a worker attempts to update a task status while disconnected, traditional apps either block the UI with infinite loading spinners or fail silently, discarding critical data. This leads to frustrated users, operational chaos, and significant data loss. Throwing a simple caching layer over HTTP requests only delays the inevitable—when connectivity returns, concurrent data mutations collide, overwriting each other in the database.
The solution is architecting a true offline-first Flutter application. In this paradigm, the local device is treated as the primary source of truth. The UI reads and writes instantly to a local database, while an asynchronous background queue handles data synchronization with a Node.js server.
In this guide, we will break down a production-grade Flutter offline-first architecture, build a robust local database sync mechanism, and implement deterministic conflict resolution on the backend.
Designing the Offline-First Local Database Architecture
An offline-first architecture fundamentally changes how data flows through your app. Instead of UI -> API -> Local Cache, the flow becomes UI -> Local DB -> Sync Queue -> API. The user never waits for a network request to complete.
To achieve this in Flutter, we use sqflite for robust local storage. Our schema requires two distinct concepts: the actual business entities (e.g., tasks) and an Event Sourcing inspired sync_queue that records every mutation.
Here is how you structure the local database initialization:
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class LocalDatabase {
static final LocalDatabase instance = LocalDatabase._init();
static Database? _database;
LocalDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('field_app.db');
return _database!;
}
Future<Database> _initDB(String filePath) async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, filePath);
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
);
}
Future<void> _createDB(Database db, int version) async {
// Run table creation in a batch for efficiency
final batch = db.batch();
// Business data table
batch.execute('''
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
status TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
''');
// Sync queue to track mutations
batch.execute('''
CREATE TABLE sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
action TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
payload TEXT NOT NULL, -- JSON representation of the change
created_at INTEGER NOT NULL,
retry_count INTEGER DEFAULT 0
)
''');
await batch.commit();
}
}
Production Note: Never use auto-incrementing integers for primary keys on business entities in an offline-first app. Two disconnected devices might both create a task locally, assigning it
id: 5. When they sync, the server will crash due to a primary key collision. Always use universally unique identifiers (UUIDs) generated on the client.
Implementing the Action-Based Sync Queue
When a user modifies a task while offline, we execute two operations in a single SQLite transaction: we update the tasks table so the UI reflects the change immediately, and we insert a record into the sync_queue table.
By syncing actions rather than raw database state, the server gains granular context about what changed and in what order. When building mission-critical logistics platforms through our Flutter mobile app development services, we rely heavily on action-based queues to prevent race conditions during batch synchronization.
Here is the Dart implementation for pushing to and processing the sync queue:
import 'dart:convert';
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:http/http.dart' as http;
class SyncManager {
final LocalDatabase _dbHelper = LocalDatabase.instance;
// 1. Transactionally update data and queue the sync
Future<void> updateTaskStatus(String taskId, String newStatus) async {
final db = await _dbHelper.database;
final timestamp = DateTime.now().millisecondsSinceEpoch;
await db.transaction((txn) async {
// Update local UI state
await txn.update(
'tasks',
{'status': newStatus, 'updated_at': timestamp},
where: 'id = ?',
whereArgs: [taskId],
);
// Queue the action
final payload = jsonEncode({'status': newStatus, 'updated_at': timestamp});
await txn.insert('sync_queue', {
'entity_id': taskId,
'entity_type': 'task',
'action': 'UPDATE',
'payload': payload,
'created_at': timestamp,
});
});
// Attempt sync immediately, but don't await it to block the UI
processQueue();
}
// 2. Process the queue sequentially
Future<void> processQueue() async {
final db = await _dbHelper.database;
// Fetch pending operations ordered by creation time
final pendingOps = await db.query(
'sync_queue',
orderBy: 'created_at ASC',
limit: 50, // Batch processing
);
if (pendingOps.isEmpty) return;
for (var op in pendingOps) {
try {
final response = await _sendToServer(op);
if (response.statusCode == 200 || response.statusCode == 409) {
// Success or handled conflict: remove from queue
await db.delete('sync_queue', where: 'id = ?', whereArgs: [op['id']]);
} else {
// Increment retry count on server errors (5xx)
await db.rawUpdate(
'UPDATE sync_queue SET retry_count = retry_count + 1 WHERE id = ?',
[op['id']]
);
}
} on SocketException {
// Network error - abort queue processing and wait for connection
break;
} catch (e) {
// Handle other unexpected errors
break;
}
}
}
Future<http.Response> _sendToServer(Map<String, dynamic> operation) async {
return await http.post(
Uri.parse('https://api.yourdomain.com/sync'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(operation),
);
}
}
Handling Connectivity and Background Sync
Attempting to process the queue when the device is completely offline wastes battery and device resources. We need to actively listen to network state changes using the connectivity_plus package to trigger our SyncManager the moment the device regains its connection.
import 'package:connectivity_plus/connectivity_plus.dart';
import 'dart:async';
class NetworkListener {
final SyncManager _syncManager = SyncManager();
StreamSubscription<List<ConnectivityResult>>? _subscription;
void initialize() {
_subscription = Connectivity().onConnectivityChanged.listen((List<ConnectivityResult> results) {
// Modern devices can have multiple active connections.
// We check if at least one valid connection exists.
if (!results.contains(ConnectivityResult.none)) {
print("Network restored. Triggering sync queue...");
_syncManager.processQueue();
}
});
}
void dispose() {
_subscription?.cancel();
}
}
Warning: Relying solely on
connectivity_plusisn't foolproof. A device might be connected to a Wi-Fi router that has lost its actual internet uplink. Always wrap your HTTP calls intry/catchblocks targetingSocketExceptionto gracefully abort the sync loop when packets fail to route.
Node.js Backend: Conflict Resolution Strategies
The hardest part of handling offline data sync and conflict resolution in Flutter apps is the backend logic. Imagine Worker A and Worker B both go offline. They both update Task 123. Worker A reconnects at 2:00 PM and syncs. Worker B reconnects at 3:00 PM and syncs. How does the server know whose update is valid?
While Conflict-free Replicated Data Types (CRDTs) are the gold standard for text collaboration, they are overkill for standard enterprise apps. Instead, we use a Last-Write-Wins (LWW) strategy backed by deterministic timestamps.
In this Node.js Express route, we examine the updated_at timestamp provided by the client's payload. If the client's timestamp is older than the database's current timestamp, the server rejects the update as a conflict.
const express = require('express');
const router = express.Router();
// Assuming a configured PostgreSQL pool or ORM like Knex/Prisma
const db = require('../db');
router.post('/sync', async (req, res) => {
const { entity_id, entity_type, action, payload } = req.body;
const parsedPayload = JSON.parse(payload);
const clientUpdatedAt = parseInt(parsedPayload.updated_at, 10);
try {
await db.query('BEGIN'); // Start transaction
if (entity_type === 'task' && action === 'UPDATE') {
// 1. Fetch current server state and apply an exclusive row-level lock
const { rows } = await db.query(
'SELECT status, updated_at FROM tasks WHERE id = $1 FOR UPDATE',
[entity_id]
);
const serverTask = rows[0];
if (!serverTask) {
await db.query('ROLLBACK');
return res.status(404).json({ error: 'Task not found' });
}
// 2. Conflict Resolution: Last-Write-Wins (LWW)
if (parseInt(serverTask.updated_at, 10) > clientUpdatedAt) {
// Conflict detected! The server has a newer version.
await db.query('ROLLBACK');
return res.status(409).json({
status: 'conflict',
message: 'Server has a newer version of this entity.',
server_state: serverTask // Return truth to client
});
}
// 3. Apply the update safely
await db.query(
'UPDATE tasks SET status = $1, updated_at = $2 WHERE id = $3',
[parsedPayload.status, clientUpdatedAt, entity_id]
);
}
await db.query('COMMIT');
res.status(200).json({ status: 'success' });
} catch (error) {
await db.query('ROLLBACK');
console.error('Sync Error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
This ensures that the latest intentional write dictates the state, regardless of when the devices actually establish an internet connection to process their sync queues.
Managing Sync States and Client-Side Reversals
When the Node.js backend returns a 409 Conflict, the Flutter client must elegantly handle the rejection. It shouldn't retry the failed operation infinitely. Instead, the client must reconcile its local database with the server's source of truth.
We update our client-side network handler to interpret the conflict response, overwrite the local SQLite data with the server's newer state, and safely remove the failed operation from the sync queue.
import 'dart:convert';
import 'package:http/http.dart' as http;
extension ConflictHandler on SyncManager {
Future<void> handleSyncResponse(http.Response response, Map<String, dynamic> operation) async {
final db = await LocalDatabase.instance.database;
final int opId = operation['id'];
if (response.statusCode == 200) {
// Success: Remove operation from queue
await db.delete('sync_queue', where: 'id = ?', whereArgs: [opId]);
}
else if (response.statusCode == 409) {
// Conflict: Server rejected our update. We must adopt the server's truth.
final responseBody = jsonDecode(response.body);
final serverState = responseBody['server_state'];
await db.transaction((txn) async {
// 1. Revert local UI state to match server
await txn.update(
'tasks',
{
'status': serverState['status'],
'updated_at': serverState['updated_at']
},
where: 'id = ?',
whereArgs: [operation['entity_id']],
);
// 2. Discard the failed sync operation
await txn.delete('sync_queue', where: 'id = ?', whereArgs: [opId]);
});
// Optional: Dispatch a UI event notifying the user their change was overwritten
print("Warning: Local changes to task ${operation['entity_id']} were overridden by a newer server update.");
}
}
}
Essential Trade-offs and Production Considerations
Building a robust Flutter local database sync and Node.js backend architecture involves managing several critical trade-offs:
- Client-Side Identity Generation: As emphasized earlier, relying on the backend database to assign IDs breaks offline-first systems. You must generate UUIDv4 strings locally before saving to SQLite.
- Payload Batching: Sending 50 separate HTTP requests for 50 queued operations will overwhelm your server and drain the mobile device's battery. In production, consolidate your sync queue into a bulk array and expose a
/sync/batchendpoint on your Node.js backend. - Clock Skew: LWW relies entirely on timestamps. If a field worker's phone is manually set to the year 2030, their offline updates will incorrectly overwrite all future data on the server. To mitigate this, capture the server's time during authentication and calculate an offset drift to apply to local timestamps.
Here is how you securely implement client-side identity generation in Dart using the uuid package:
import 'package:uuid/uuid.dart';
class TaskService {
final _uuid = const Uuid();
final LocalDatabase _db = LocalDatabase.instance;
Future<String> createOfflineTask(String title) async {
final db = await _db.database;
// Generate UUIDv4 instantly on the client
final taskId = _uuid.v4();
final timestamp = DateTime.now().millisecondsSinceEpoch;
await db.insert('tasks', {
'id': taskId,
'title': title,
'status': 'PENDING',
'updated_at': timestamp,
});
// Continue with queuing the INSERT operation...
return taskId;
}
}
By embracing local-first principles, utilizing robust SQLite databases, and implementing strict version control on your Node.js backend, you can build field apps that feel blazingly fast regardless of network conditions.
Work With Us
Need help building this in production? SoftwareCrafting is a full-stack dev agency — we ship React, Next.js, Node.js, React Native & Flutter apps for global clients.
Book a free architecture review and talk to our backend engineers →
Frequently Asked Questions
Why shouldn't I use auto-incrementing integers for primary keys in an offline-first Flutter app?
Using auto-incrementing integers can lead to primary key collisions when multiple disconnected devices create records simultaneously and later sync with the server. Instead, you should always generate Universally Unique Identifiers (UUIDs) on the client side to ensure every record remains unique across distributed devices.
How does data flow differ in an offline-first architecture compared to traditional API-driven apps?
In a traditional app, the flow is typically UI -> API -> Local Cache, which blocks the user or fails if the network drops. In an offline-first architecture, the flow changes to UI -> Local DB -> Sync Queue -> API, meaning the user interacts instantly with the local SQLite database while network requests happen asynchronously in the background.
What is the purpose of the sync_queue table in the SQLite schema?
The sync_queue table acts as an action-based ledger inspired by Event Sourcing, recording every mutation (INSERT, UPDATE, DELETE) made while the device is offline. By syncing these specific actions rather than the raw database state, the backend server gains granular context to process changes in the correct order.
How can I ensure my offline-first sync queue doesn't suffer from race conditions during batch synchronization?
You should implement an action-based queue within a single SQLite transaction, ensuring that local UI updates and sync events are recorded atomically. If your enterprise application requires complex synchronization patterns, our SoftwareCrafting team offers specialized Flutter mobile app development services to architect resilient, race-condition-free mobile platforms.
How does the Node.js backend handle data conflicts when multiple offline devices reconnect?
The backend processes the action-based sync queue sequentially, using the timestamps and specific mutation payloads provided by the client to apply deterministic conflict resolution. If your team needs help building robust synchronization APIs, SoftwareCrafting provides expert backend development services to architect reliable Node.js servers capable of handling complex offline-first data merging.
How do offline-first apps prevent the UI from freezing during network outages?
By treating the local SQLite database as the primary source of truth, the UI reads and writes data instantly without waiting for HTTP requests to resolve. The actual network synchronization is deferred to an asynchronous background queue, which completely eliminates infinite loading spinners and silent data failures during spotty connectivity.
📎 Full Code on GitHub Gist: The complete
local_database.dartfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
