Building Local-First Flutter Apps with Riverpod, Drift, and PowerSync


Introduction
Mobile app users increasingly expect seamless experiences regardless of network conditions. Whether on a subway, in an elevator, or in areas with spotty reception, users want to access their data, make changes, and continue using apps without interruption.
This expectation has driven the rise of local-first application architecture, which prioritises local data storage and synchronisation to deliver superior user experiences.
You can see this architecture in action across many popular apps:
Note-taking apps like Notion or Obsidian that let you write offline
Project management tools like Trello that queue changes when you're disconnected
Email clients like Gmail's offline mode that store messages locally
Mapping applications that download regions for offline navigation
In this article, I want to show you how local-first apps work and an example of how you can build one using:
Riverpod for state management and dependency injection
Drift for local database storage
PowerSync for synchronising with your backend
This sounds complex, but with the right architecture and tools, creating robust offline-capable apps becomes surprisingly manageable. Let's dive in!
Understanding Online-First vs. Local-First Architecture
Traditional Online-First Approach
Most Flutter apps follow an online-first approach. They rely on making immediate API calls (REST, GraphQL, or SDK-based) to a remote server, then updating local state based on the response. Local storage options like SharedPreferences
, FlutterSecureStorage
,or a local database might be used alongside the API, but the remote server remains the primary source of truth.
In a traditional online-first app using Riverpod, the architecture typically looks like this:
Let's examine what happens in the data layer of an online-first app, using a Todo application as an example:
class OnlineFirstRepository {
final ApiClient api;
final AppDatabase db;
OnlineFirstRepository(this.api, this.db);
Future<Todo> createTodo(String title) async {
// Create directly on server
final response = await api.post('/todos', {'title': title, 'completed': false});
// Convert response to Todo model
final todo = Todo.fromJson(response);
// Save to local database
await db.into(db.todos).insert(todo.toCompanion(synced: true));
return todo;
}
}
The data flow is straightforward:
Create the todo on the remote server
Save the server's response locally
This approach works well when the user has reliable internet connectivity. But what happens when they don't?
The Local-First Paradigm Shift
Local-first architecture flips this model by prioritising local storage as the primary source of truth. The data flow becomes:
Create the todo locally first
Sync the change to the remote server when connectivity is available
This reversal seems subtle, but fundamentally changes how we build apps. Let's compare the approaches visually:
Here's how the repository would look in a local-first implementation:
class LocalFirstRepository {
final AppDatabase db;
final SyncEngine syncEngine;
LocalFirstRepository(this.db, this.syncEngine);
Future<Todo> createTodo(String title) async {
// Create locally first with generated ID
final todo = Todo(
id: generateUuid(),
title: title,
completed: false,
synced: false
);
// Save to local database
await db.into(db.todos).insert(todo.toCompanion());
// Queue for sync
syncEngine.queueChange('create', todo.id, todo.toJson());
return todo;
}
}
There are two crucial differences between this local-first approach and the online-first model:
Reversed data flow: Changes are applied locally first, then synchronised remotely
Decoupled synchronisation: Syncing happens in the background without requiring user interaction
This shift means the local database becomes the primary source of truth, which has several implications:
The app reads from and writes directly to the local database
UI components react to changes in the local database, not the remote one
Synchronisation runs in the background, independent of user interactions
Looking at the diagram above, you'll notice the introduction of a sync engine component that sits between the local database and the remote server. The diagram shows a simple version, but implementing a sync engine introduces numerous technical challenges. In fact, it often becomes the most complex part of a local-first application.
The Challenges of Building a Sync Engine
Building a robust sync engine isn't trivial. When I first attempted to build one, I quickly discovered a number of challenges that go far beyond simple data transfer.
Change Tracking
One of the first problems is tracking all modifications that occur while a device is offline. Your app needs to record every create, update, and delete operation in a queue for future synchronisation. This isn't just about storing the data—it's about preserving the exact sequence of operations to maintain consistency across devices.
Imagine a scenario where a user who creates a new task, updates its title twice, and then marks it complete—all while on airplane mode. Your sync engine must capture each step in the correct order to ensure proper synchronisation when connectivity returns.
Conflict Resolution
Perhaps the most challenging aspect is handling conflicts when the same data has been modified in different ways across devices. When two users edit the same task title offline, whose changes should prevail when they reconnect.
A simple conflict resolution approach might look like this:
Future<Map<String, dynamic>> resolveConflict(
PendingChange localChange,
Map<String, dynamic> serverData
) async {
if (localChange.timestamp > serverData['last_modified']) {
return localChange.data;
} else {
return _mergeChanges(serverData, localChange.data);
}
}
While timestamp-based resolution is straightforward, more sophisticated approaches might use field-level merging or even custom logic based on the specific data type. The goal is always to preserve user intent while maintaining overall data integrity.
Edge Cases & Error Handling
The edge cases in sync engines can quickly become overwhelming. What happens when a user creates a task and then deletes it before ever synchronising? Should that operation even be sent to the server? What about connectivity issues mid-sync that leave your database in a partially updated state?
Server-side validation adds another layer of complexity. A change that seems valid on the device might be rejected by the server due to business rules or data changes made elsewhere. Your sync engine needs strategies for handling these rejections gracefully, perhaps by attempting to merge changes or prompting the user to resolve conflicts manually.
Retry logic also becomes essential—too few retries and your data might never sync, but too many aggressive retries can drain battery and bandwidth.
Performance Optimisation
Performance considerations underlie every aspect of sync engine design. Batching multiple changes into a single network request reduces overhead but increases the complexity of error handling. Background synchronisation helps maintain a responsive UI but requires careful management to minimise battery impact.
For apps with large datasets, you'll need efficient database operations that can handle thousands of records without blocking the main thread or consuming excessive memory. This often requires implementing pagination, selective synchronisation, or delta updates rather than transferring entire datasets.
PowerSync
Building all these capabilities from scratch is time-consuming and error-prone. This is where specialised tools can help. PowerSync provides a solution for these synchronization challenges while integrating with Flutter's ecosystem.
PowerSync handles the complex synchronisation process, including:
Managing offline data storage
Tracking and queuing changes
Synchronising data when connectivity is restored
Resolving conflicts according to defined strategies
Providing real-time updates across devices
The integration with Drift (via drift_sqlite_async
package) means you can use familiar ORM patterns while PowerSync handles the synchronization in the background. All platforms are supported, including Web (support is in beta at the moment of writing).
Practical Implementation: Todo App
Let's examine how to implement a Todo app using Riverpod, Drift, and PowerSync connected to a Supabase backend. We'll focus on key parts of the implementation to see how these libraries work together.
Before diving into the code, make sure to check out the repo and set it up properly by following the instructions in the readme file.
Github repo is available here.
Setting Up PowerSync with Supabase
First, we need to set up PowerSync and connect it to Supabase:
@Riverpod(keepAlive: true)
Future<PowerSyncDatabase> powerSyncInstance(Ref ref) async {
final db = PowerSyncDatabase(
schema: schema,
path: await _getDatabasePath(),
logger: attachedLogger,
);
await db.initialize();
SupabaseConnector? currentConnector;
if (ref.read(sessionProvider).value != null) {
currentConnector = SupabaseConnector();
db.connect(connector: currentConnector);
}
final instance = Supabase.instance.client.auth;
final sub = instance.onAuthStateChange.listen((data) async {
final event = data.event;
if (event == AuthChangeEvent.signedIn) {
currentConnector = SupabaseConnector();
db.connect(connector: currentConnector!);
} else if (event == AuthChangeEvent.signedOut) {
currentConnector = null;
await db.disconnect();
} else if (event == AuthChangeEvent.tokenRefreshed) {
currentConnector?.prefetchCredentials();
}
});
ref.onDispose(sub.cancel);
ref.onDispose(db.close);
return db;
}
The powerSyncInstance
provider creates and manages the state of the Supabase connector internally depending on the authentication change. This is the central piece that will be used throughout the app in order to facilitate synchronisation.
Defining the Database
Next, we define our database using Drift (the full schema is available in the repo):
@DriftDatabase(
tables: [TodoItems, ListItems],
include: {'queries.drift'},
)
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
// Other database methods...
}
final driftDatabase = Provider((ref) {
return AppDatabase(DatabaseConnection.delayed(Future(() async {
final database = await ref.read(powerSyncInstanceProvider.future);
return SqliteAsyncDriftConnection(database);
})));
});
Notice how we connect Drift to PowerSync using SqliteAsyncDriftConnection
. This integration allows Drift to work with PowerSync's underlying database.
Notifier
With our database set up, we can create a Riverpod notifier for managing the items:
@riverpod
final class ItemsNotifier extends _$ItemsNotifier {
@override
Stream<List<TodoItem>> build(String list) {
final database = ref.watch(driftDatabase);
final query = database.select(database.todoItems)
..where((row) => row.listId.equals(list))
..orderBy([(t) => OrderingTerm(expression: t.createdAt)]);
return query.watch();
}
Future<void> addItem(String description) async {
final db = ref.read(driftDatabase);
final userId = ref.read(userIdProvider);
await db.into(db.todoItems).insertReturning(
TodoItemsCompanion.insert(
listId: list,
description: description,
completed: const Value(false),
createdBy: Value(userId),
),
);
}
// other methods like toggleItem and deleteItem ...
}
This notifier:
Provides a stream of todo items for a specific list
Supports adding new items
Supports other actions like toggle, delete, etc.
Notice that operations are performed directly on the local database. PowerSync automatically syncs these changes with the remote server in the background.
Using the Notifier in the UI
Finally, we can use our notifier in the UI:
// observing the notifier in the UI
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(itemsNotifierProvider(list));
return items.maybeWhen(
data: (items) => ListView(
padding: const EdgeInsets.symmetric(vertical: 8.0),
children: items.map((todo) {
return _TodoItemWidget(
todo: todo,
);
}).toList(),
),
orElse: () => const CircularProgressIndicator(),
);
}
// creating a new item
final controller = useTextEditingController();
Future<void> add() async {
await ref
.read(itemsNotifierProvider(list).notifier)
.addItem(controller.text);
}
The UI components react to changes in the local database through Riverpod's state management. When a user performs an action:
The change is immediately applied locally
The UI updates instantly
PowerSync handles synchronisation with the server in the background
This creates a responsive, seamless experience for users regardless of their network connectivity.
Conclusion
Building local-first Flutter applications delivers a responsive experience that works regardless of network conditions. By prioritising local storage and handling synchronisation in the background, your apps feel consistently fast and reliable to users.
While the challenges of building a sync engine are significant, combining Riverpod, Drift, and PowerSync creates a powerful stack that makes local-first development approachable. You can focus on building features rather than wrestling with complex synchronisation logic.
I'd like to thank PowerSync for sponsoring this blog post and supporting content that helps Flutter developers build better offline-first applications.
As users increasingly expect apps to work everywhere, all the time, a local-first architecture isn't just a nice-to-have feature—it's becoming essential for delivering the seamless experiences your users demand.
If you have found this useful, make sure to like and follow for more content like this. To know when the new articles are coming out, follow me on Twitter and LinkedIn.
Subscribe to my newsletter
Read articles from Dinko Marinac directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Dinko Marinac
Dinko Marinac
Mobile app developer and consultant. CEO @ MOBILAPP Solutions. Passionate about the Dart & Flutter ecosystem.