← Back to posts Cover image for Mastering Flutter Offline Data: Building Resilient Apps with Supabase and Local Caching

Mastering Flutter Offline Data: Building Resilient Apps with Supabase and Local Caching

· 4 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Building Apps That Work Anywhere: Offline-First Flutter with Supabase

We’ve all been there. A user is on a train, deep in a tunnel, crafting a crucial piece of content in your app. They hit “save,” the network flickers, and their work vanishes. For apps dependent on backend services like Supabase, this offline scenario is more than an edge case—it’s a user experience killer that leads to frustration and data loss.

The core problem is simple: most real-time client libraries, including supabase_flutter, are designed for online operation. Network requests fail immediately when offline, leaving your app in a broken state. The solution is an offline-first architecture. This approach assumes connectivity is intermittent and prioritizes local data persistence, syncing to the server only when possible.

Let’s build a resilient Flutter app using Supabase for the backend and Drift for local caching. This combination gives us a robust SQL database locally and a seamless sync mechanism to Supabase Postgres.

Setting Up the Local Database with Drift

First, add the necessary dependencies to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  drift: ^2.14.0
  sqlite3_flutter_libs: ^0.5.0
  path_provider: ^2.1.0
  path: ^1.9.0
  supabase_flutter: ^2.1.2

dev_dependencies:
  drift_dev: ^2.13.0
  build_runner: ^2.4.0

We’ll create a simple database to store user-generated notes. The key is to structure our local tables to mirror our Supabase remote tables.

// 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;

part 'database.g.dart';

class LocalNotes extends Table {
  TextColumn get id => text().clientDefault(() => DateTime.now().millisecondsSinceEpoch.toString())();
  TextColumn get remoteId => text().nullable()();
  TextColumn get title => text()();
  TextColumn get content => text()();
  DateTimeColumn get createdAt => dateTime()();
  BoolColumn get isSynced => boolean().withDefault(const Constant(false))();
}

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

  @override
  int get schemaVersion => 1;

  Future<void> insertNote(LocalNotesCompanion entry) => into(localNotes).insert(entry);
  Future<List<LocalNote>> getAllNotes() => select(localNotes).get();
  Future<void> markNoteAsSynced(String localId, String remoteId) {
    return (update(localNotes)..where((tbl) => tbl.id.equals(localId))).write(
      LocalNotesCompanion(
        isSynced: const Value(true),
        remoteId: Value(remoteId),
      ),
    );
  }
}

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

Run flutter pub run build_runner build to generate the necessary code.

Creating the Offline-Aware Repository

The heart of our strategy is a repository that acts as a gatekeeper. It always writes to the local database first, then attempts to sync in the background.

// note_repository.dart
import 'package:supabase_flutter/supabase_flutter.dart';

class NoteRepository {
  final AppDatabase _localDb;
  final SupabaseClient _supabaseClient;

  NoteRepository(this._localDb, this._supabaseClient);

  Future<void> saveNote({required String title, required String content}) async {
    final companion = LocalNotesCompanion(
      title: Value(title),
      content: Value(content),
      createdAt: Value(DateTime.now()),
    );

    // 1. Unconditionally save to local DB
    await _localDb.insertNote(companion);

    // 2. Attempt to sync to Supabase
    try {
      final response = await _supabaseClient
          .from('notes')
          .insert({
            'title': title,
            'content': content,
            'created_at': DateTime.now().toIso8601String(),
          })
          .select('id')
          .single()
          .timeout(const Duration(seconds: 10));

      // 3. On success, update local record
      // Need the local ID from the inserted note to call markNoteAsSynced
    } catch (e) {
      print('Sync failed. Note remains locally. Error: $e');
    }
  }

  Stream<List<LocalNote>> watchNotes() {
    return _localDb.select(_localDb.localNotes).watch();
  }
}

Implementing a Sync Manager

For a more complete solution, you need a background process to retry failed syncs.

// sync_manager.dart
import 'package:connectivity_plus/connectivity_plus.dart';

class SyncManager {
  final AppDatabase _db;
  final SupabaseClient _supabase;

  SyncManager(this._db, this._supabase);

  Future<void> syncPendingNotes() async {
    final pendingNotes = await (_db.select(_db.localNotes)
          ..where((tbl) => tbl.isSynced.equals(false)))
        .get();

    for (final note in pendingNotes) {
      try {
        final response = await _supabase
            .from('notes')
            .insert({
              'title': note.title,
              'content': note.content,
              'created_at': note.createdAt.toIso8601String(),
            })
            .select('id')
            .single();

        await _db.markNoteAsSynced(note.id, response['id'] as String);
      } catch (e) {
        print('Failed to sync note ${note.id}: $e');
      }
    }
  }
}

Common Pitfalls and Best Practices

  1. Conflict Resolution: What happens if a user edits a note offline that was already synced and edited on another device? You need a strategy (e.g., “last write wins” or manual conflict resolution). Consider adding a lastModified field to your records.
  2. Database Initialization: Always ensure your local database is initialized before use. The LazyDatabase wrapper in Drift helps with this.
  3. UI Feedback: Clearly inform users about sync status. Use the isSynced flag to show an “offline” or “syncing” indicator next to their data.
  4. Batching Syncs: For apps with high data turnover, batch your sync operations to reduce network calls.

By adopting this offline-first pattern, you transform your app from a fragile network-dependent client into a resilient tool that users can trust anywhere. The local cache provides instant UI feedback and data safety, while Supabase handles secure, scalable backend storage when connected.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter's Hidden Gem: Building Powerful Linux Desktop Apps with Ease

Flutter is gaining traction as a robust framework for Linux desktop development, offering a smoother experience compared to traditional toolkits like GTK and Qt. This post will explore why Flutter excels for Linux apps, highlight its advantages, and provide practical insights for developers looking to leverage Flutter for their desktop projects.

Cover image for Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Simplifying Flutter Desktop Deployment: Signing and Distribution for Windows

Deploying Flutter desktop apps on Windows can be challenging, especially regarding code signing and preventing Windows from blocking installations. This post will guide developers through the process of signing their Flutter desktop applications, exploring alternatives to the Microsoft Store like creating executable installers, and covering the necessary steps to ensure a smooth user experience.

Cover image for Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Mastering Flutter Tooling: Streamlining SDK Management and Installation on Windows

Developers frequently voice frustrations about Flutter SDK version management and installation processes, particularly on Windows. This post will explore best practices for managing multiple Flutter versions using tools like FVM, discuss integrating Flutter with package managers like Winget for a smoother Windows setup, and offer strategies to ensure a consistent development environment across projects and teams.