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
Localizing Dynamic Content in Flutter: A Guide to Backend-Driven Translations
Many Flutter apps need to display content that changes based on user locale, but also comes from a backend (like Firebase). This post will explore best practices for fetching and integrating dynamic, localized content from a backend, ensuring a seamless user experience across different languages and regions without hardcoding translations.
Unraveling Type Mismatch Errors in Flutter: A Guide to 'X can't be assigned to Y' and '_InternalLinkedHashMap' Issues
Developers frequently encounter cryptic type mismatch errors like 'The argument type X can't be assigned to the parameter type Y' or '_InternalLinkedHashMap has no instance method 'cast''. This post will demystify these common Flutter/Dart type errors, explain their root causes (e.g., conflicting imports, dynamic typing pitfalls, JSON deserialization issues), and provide practical solutions to diagnose and fix them, improving code robustness and reducing debugging time.
Solving Flutter Web Memory Leaks: A Practical Guide to Identifying and Fixing Performance Issues
Flutter Web applications can suffer from increasing memory usage over time, leading to performance degradation. This post will delve into common causes of memory leaks in Flutter Web, provide practical debugging techniques using browser developer tools and Dart DevTools, and offer actionable strategies to identify and fix these issues for a smoother user experience.