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 App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB
Flutter apps are often criticized for their larger-than-native binary sizes. This post will dive into practical, actionable strategies to significantly reduce your Flutter app's footprint, including build configurations, asset optimization, and dependency management, demonstrating how to achieve app sizes under 10MB.
Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls
Implementing in-app subscriptions in Flutter can be surprisingly complex, especially when dealing with cross-platform nuances, receipt validation, and managing different store configurations. This post will delve into the common challenges developers face, compare solutions like RevenueCat with custom implementations, and provide best practices for setting up and maintaining a robust subscription system.
Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter
Android emulators are notorious for hogging RAM, often pushing developer machines into swap and slowing down Flutter development. This post will explore the reasons behind high emulator memory consumption and provide practical, actionable strategies and tools (including a custom-built solution) to significantly reduce RAM usage, ensuring a smoother development experience without sacrificing functionality.