Mastering Flutter Offline Data: Building Resilient Apps with Supabase and Local Caching
The Flutter news you actually need
No spam, ever. Unsubscribe in one click.
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
- 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
lastModifiedfield to your records. - Database Initialization: Always ensure your local database is initialized before use. The
LazyDatabasewrapper in Drift helps with this. - UI Feedback: Clearly inform users about sync status. Use the
isSyncedflag to show an “offline” or “syncing” indicator next to their data. - 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
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.
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.
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.