Data Synchronization: Keeping Your Flutter App in Sync When Offline

Hey everyone, Jamie here.

We've all been there: You're on the train, underground, or just dealing with spotty Wi-Fi, and your app becomes a digital paperweight the moment connectivity drops. Meanwhile, users are trying to capture that important note, complete a task, or continue working – but everything grinds to a halt because your app can't reach the server.

This is where offline-first design and data synchronization become game-changers. Building apps that work seamlessly offline and intelligently sync when connectivity returns isn't just a nice-to-have anymore – it's what users expect. Let's explore how to build robust offline capabilities with Laravel and Flutter that'll keep your users productive regardless of their connection status.

The Offline-First Mindset

Before diving into implementation, let's establish the core principles:

Flutter: Building the Local Database Foundation

The key to offline functionality is having a robust local database. Flutter offers several excellent options, but I'll focus on the most practical approaches.

SQLite with Drift (Formerly Moor)

Drift is a powerful, type-safe SQLite wrapper that makes complex queries and migrations straightforward:

dependencies:
  drift: ^2.14.1
  sqlite3_flutter_libs: ^0.5.0
  path_provider: ^2.0.0
  path: ^1.8.0

dev_dependencies:
  drift_dev: ^2.14.1
  build_runner: ^2.3.0

Setting up your local schema:

// database.dart
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

// Tables
class Tasks extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 200)();
  TextColumn get description => text().nullable()();
  BoolColumn get isCompleted => boolean().withDefault(const Constant(false))();
  DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
  DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
  
  // Sync fields
  IntColumn get serverId => integer().nullable()(); // Server-side ID
  BoolColumn get needsSync => boolean().withDefault(const Constant(true))();
  TextColumn get syncAction => text().nullable()(); // 'create', 'update', 'delete'
  DateTimeColumn get lastSyncAt => dateTime().nullable()();
}

@DriftDatabase(tables: [Tasks])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  static LazyDatabase _openConnection() {
    return LazyDatabase(() async {
      final dbFolder = await getApplicationDocumentsDirectory();
      final file = File(p.join(dbFolder.path, 'app_database.db'));
      return NativeDatabase(file);
    });
  }
}

The Sync-Aware Repository Pattern:

class TaskRepository {
  final AppDatabase _db;
  
  TaskRepository(this._db);
  
  // Get all tasks (works offline)
  Stream<List<Task>> watchAllTasks() {
    return _db.select(_db.tasks).watch();
  }
  
  // Create task (works offline)
  Future<Task> createTask(String title, String? description) async {
    final task = TasksCompanion(
      title: Value(title),
      description: Value(description),
      needsSync: const Value(true),
      syncAction: const Value('create'),
    );
    
    final id = await _db.into(_db.tasks).insert(task);
    final createdTask = await (_db.select(_db.tasks)..where((t) => t.id.equals(id))).getSingle();
    
    // Trigger background sync
    _scheduleSync();
    
    return createdTask;
  }
  
  // Update task (works offline)
  Future<void> updateTask(Task task, {String? title, String? description, bool? isCompleted}) async {
    final update = TasksCompanion(
      id: Value(task.id),
      title: title != null ? Value(title) : const Value.absent(),
      description: description != null ? Value(description) : const Value.absent(),
      isCompleted: isCompleted != null ? Value(isCompleted) : const Value.absent(),
      updatedAt: Value(DateTime.now()),
      needsSync: const Value(true),
      syncAction: Value(task.serverId != null ? 'update' : 'create'),
    );
    
    await (_db.update(_db.tasks)..where((t) => t.id.equals(task.id))).write(update);
    _scheduleSync();
  }
  
  // Soft delete (works offline)
  Future<void> deleteTask(Task task) async {
    if (task.serverId != null) {
      // Mark for deletion sync
      await (_db.update(_db.tasks)..where((t) => t.id.equals(task.id))).write(
        const TasksCompanion(
          needsSync: Value(true),
          syncAction: Value('delete'),
        ),
      );
    } else {
      // Local-only task, delete immediately
      await (_db.delete(_db.tasks)..where((t) => t.id.equals(task.id))).go();
    }
    _scheduleSync();
  }
  
  void _scheduleSync() {
    // Trigger your sync service
    SyncService.instance.scheduleSync();
  }
}

Laravel: Building Sync-Friendly APIs

Your Laravel backend needs to support efficient synchronization. This means designing APIs that can handle batch operations, conflict resolution, and incremental updates.

Sync-Aware Models

Start by adding sync metadata to your Eloquent models:

// app/Models/Task.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Task extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'title',
        'description',
        'is_completed',
        'user_id',
    ];

    protected $casts = [
        'is_completed' => 'boolean',
        'created_at' => 'datetime',
        'updated_at' => 'datetime',
        'deleted_at' => 'datetime',
    ];

    // Add a sync version for conflict resolution
    protected static function boot()
    {
        parent::boot();
        
        static::updating(function ($task) {
            $task->sync_version = ($task->sync_version ?? 0) + 1;
        });
    }
}

Migration for sync support:

// database/migrations/add_sync_fields_to_tasks_table.php
public function up()
{
    Schema::table('tasks', function (Blueprint $table) {
        $table->integer('sync_version')->default(1);
        $table->timestamp('client_updated_at')->nullable();
    });
}

Batch Sync Endpoints

Design your API to handle batch operations efficiently:

// app/Http/Controllers/SyncController.php
<?php

namespace App\Http\Controllers;

use App\Models\Task;
use Illuminate\Http\Request;

class SyncController extends Controller
{
    public function pullChanges(Request $request)
    {
        $lastSyncAt = $request->input('last_sync_at');
        $lastSyncAt = $lastSyncAt ? Carbon::parse($lastSyncAt) : null;

        $query = auth()->user()->tasks();

        if ($lastSyncAt) {
            $query->where(function ($q) use ($lastSyncAt) {
                $q->where('updated_at', '>', $lastSyncAt)
                  ->orWhere('deleted_at', '>', $lastSyncAt);
            });
        }

        $tasks = $query->withTrashed()->get();

        return response()->json([
            'tasks' => $tasks,
            'server_time' => now()->toISOString(),
        ]);
    }

    public function pushChanges(Request $request)
    {
        $changes = $request->input('changes', []);
        $results = [];

        foreach ($changes as $change) {
            $result = $this->processChange($change);
            $results[] = $result;
        }

        return response()->json([
            'results' => $results,
            'server_time' => now()->toISOString(),
        ]);
    }

    private function processChange(array $change)
    {
        $action = $change['action']; // 'create', 'update', 'delete'
        $clientId = $change['client_id'];
        $data = $change['data'];

        try {
            switch ($action) {
                case 'create':
                    $task = auth()->user()->tasks()->create($data);
                    return [
                        'client_id' => $clientId,
                        'status' => 'success',
                        'server_id' => $task->id,
                        'sync_version' => $task->sync_version,
                    ];

                case 'update':
                    $task = auth()->user()->tasks()->find($data['id']);
                    
                    if (!$task) {
                        return [
                            'client_id' => $clientId,
                            'status' => 'not_found',
                        ];
                    }

                    // Conflict detection
                    if (isset($data['sync_version']) && $task->sync_version > $data['sync_version']) {
                        return [
                            'client_id' => $clientId,
                            'status' => 'conflict',
                            'server_data' => $task->toArray(),
                        ];
                    }

                    $task->update($data);
                    
                    return [
                        'client_id' => $clientId,
                        'status' => 'success',
                        'sync_version' => $task->sync_version,
                    ];

                case 'delete':
                    $task = auth()->user()->tasks()->find($data['id']);
                    
                    if ($task) {
                        $task->delete();
                    }
                    
                    return [
                        'client_id' => $clientId,
                        'status' => 'success',
                    ];

                default:
                    return [
                        'client_id' => $clientId,
                        'status' => 'invalid_action',
                    ];
            }
        } catch (\Exception $e) {
            return [
                'client_id' => $clientId,
                'status' => 'error',
                'message' => $e->getMessage(),
            ];
        }
    }
}

The Synchronization Engine

The heart of your offline-first app is the sync service that orchestrates data flow between local storage and your server.

// sync_service.dart
import 'dart:async';
import 'dart:convert';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:http/http.dart' as http;

class SyncService {
  static final SyncService instance = SyncService._internal();
  SyncService._internal();

  final AppDatabase _db = AppDatabase();
  Timer? _syncTimer;
  bool _isSyncing = false;
  DateTime? _lastSyncAt;

  // Stream to notify UI about sync status
  final _syncStatusController = StreamController<SyncStatus>.broadcast();
  Stream<SyncStatus> get syncStatusStream => _syncStatusController.stream;

  void initialize() {
    // Listen for connectivity changes
    Connectivity().onConnectivityChanged.listen((result) {
      if (result != ConnectivityResult.none) {
        scheduleSync();
      }
    });

    // Periodic sync when online
    _syncTimer = Timer.periodic(const Duration(minutes: 5), (_) {
      scheduleSync();
    });
  }

  void scheduleSync() {
    if (_isSyncing) return;
    
    // Check connectivity first
    Connectivity().checkConnectivity().then((result) {
      if (result != ConnectivityResult.none) {
        _performSync();
      }
    });
  }

  Future<void> _performSync() async {
    if (_isSyncing) return;
    
    _isSyncing = true;
    _syncStatusController.add(SyncStatus.syncing);

    try {
      // Step 1: Pull changes from server
      await _pullFromServer();
      
      // Step 2: Push local changes to server  
      await _pushToServer();
      
      _lastSyncAt = DateTime.now();
      _syncStatusController.add(SyncStatus.success);
      
    } catch (e) {
      print('Sync failed: $e');
      _syncStatusController.add(SyncStatus.error);
    } finally {
      _isSyncing = false;
    }
  }

  Future<void> _pullFromServer() async {
    final response = await http.get(
      Uri.parse('${ApiConfig.baseUrl}/sync/pull'),
      headers: {
        'Authorization': 'Bearer ${await AuthService.getToken()}',
        'Content-Type': 'application/json',
      },
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      final serverTasks = data['tasks'] as List;

      for (final taskData in serverTasks) {
        await _mergeServerTask(taskData);
      }
    }
  }

  Future<void> _mergeServerTask(Map<String, dynamic> serverTask) async {
    final serverId = serverTask['id'];
    final isDeleted = serverTask['deleted_at'] != null;
    
    // Find existing local task by server ID
    final existingTask = await (_db.select(_db.tasks)
        ..where((t) => t.serverId.equals(serverId)))
        .getSingleOrNull();

    if (isDeleted) {
      // Handle server deletion
      if (existingTask != null) {
        await (_db.delete(_db.tasks)..where((t) => t.id.equals(existingTask.id))).go();
      }
      return;
    }

    if (existingTask != null) {
      // Update existing task (server wins for now - you could implement smarter conflict resolution)
      await (_db.update(_db.tasks)..where((t) => t.id.equals(existingTask.id))).write(
        TasksCompanion(
          title: Value(serverTask['title']),
          description: Value(serverTask['description']),
          isCompleted: Value(serverTask['is_completed']),
          updatedAt: Value(DateTime.parse(serverTask['updated_at'])),
          needsSync: const Value(false),
          lastSyncAt: Value(DateTime.now()),
        ),
      );
    } else {
      // Create new task from server
      await _db.into(_db.tasks).insert(TasksCompanion(
        serverId: Value(serverId),
        title: Value(serverTask['title']),
        description: Value(serverTask['description']),
        isCompleted: Value(serverTask['is_completed']),
        createdAt: Value(DateTime.parse(serverTask['created_at'])),
        updatedAt: Value(DateTime.parse(serverTask['updated_at'])),
        needsSync: const Value(false),
        lastSyncAt: Value(DateTime.now()),
      ));
    }
  }

  Future<void> _pushToServer() async {
    // Get all items that need syncing
    final itemsToSync = await (_db.select(_db.tasks)
        ..where((t) => t.needsSync.equals(true)))
        .get();

    if (itemsToSync.isEmpty) return;

    final changes = itemsToSync.map((task) => {
      'client_id': task.id,
      'action': task.syncAction ?? 'update',
      'data': {
        if (task.serverId != null) 'id': task.serverId,
        'title': task.title,
        'description': task.description,
        'is_completed': task.isCompleted,
        'client_updated_at': task.updatedAt.toIso8601String(),
      },
    }).toList();

    final response = await http.post(
      Uri.parse('${ApiConfig.baseUrl}/sync/push'),
      headers: {
        'Authorization': 'Bearer ${await AuthService.getToken()}',
        'Content-Type': 'application/json',
      },
      body: jsonEncode({'changes': changes}),
    );

    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      final results = data['results'] as List;

      for (final result in results) {
        await _handleSyncResult(result);
      }
    }
  }

  Future<void> _handleSyncResult(Map<String, dynamic> result) async {
    final clientId = result['client_id'];
    final status = result['status'];

    final task = await (_db.select(_db.tasks)
        ..where((t) => t.id.equals(clientId)))
        .getSingleOrNull();

    if (task == null) return;

    switch (status) {
      case 'success':
        // Update with server ID if it's a new item
        final serverId = result['server_id'];
        await (_db.update(_db.tasks)..where((t) => t.id.equals(clientId))).write(
          TasksCompanion(
            serverId: serverId != null ? Value(serverId) : const Value.absent(),
            needsSync: const Value(false),
            syncAction: const Value.absent(),
            lastSyncAt: Value(DateTime.now()),
          ),
        );
        break;

      case 'conflict':
        // Handle conflict - for now, server wins, but you could present UI for user to resolve
        final serverData = result['server_data'];
        await _mergeServerTask(serverData);
        break;

      case 'not_found':
        // Item doesn't exist on server, might have been deleted
        await (_db.delete(_db.tasks)..where((t) => t.id.equals(clientId))).go();
        break;

      case 'error':
        // Keep for retry - could implement exponential backoff
        print('Sync error for item $clientId: ${result['message']}');
        break;
    }
  }

  void dispose() {
    _syncTimer?.cancel();
    _syncStatusController.close();
  }
}

enum SyncStatus { idle, syncing, success, error }
```.fromJson(messageData)));
    });
  }
  
  void _onMessageReceived(MessageReceived event, Emitter<ChatState> emit) {
    // Update state with new message
  }
}

Production Considerations

The Pragmatic Approach

Real-time features can make your app feel magical, but they also add complexity. Start simple:

  1. Identify Real Needs: Not every update needs to be real-time. Sometimes “eventual consistency” is perfectly fine.
  2. Start with SSE: If you primarily need server-to-client updates, SSE is simpler than full WebSockets.
  3. Use Hosted Solutions Initially: Pusher or Ably can get you moving quickly. You can always self-host later.
  4. Test Network Conditions: Real-time features behave differently on poor connections. Test accordingly.

Real-time communication transforms user experiences, making apps feel responsive and alive. Both Laravel and Flutter provide excellent tools to make this happen smoothly.

What real-time features are you planning to build? Any challenges you've faced with WebSockets or SSE? Let's chat about it!

Cheers,

Jamie C