Local First
Local-first data layer for Flutter applications that keeps your data available offline and synchronizes data on live using multiple strategies when the network returns.
Example apps:
- counter_app - Real-time shared counter with multi-user sync
- chat_app - Real-time chat with WebSocket + Periodic dual sync strategy
This solution is 100% self hosted and low bandwith. It can be customized combining the usage of multiple packages, such as:
Core
local_first: core client, repositories, in memory storages, sync contracts and common utilities.
Storage adapters
local_first_hive_storage: Hive adapter (schema-less boxes).local_first_sqlite_storage: SQLite adapter (structured tables with indexes).local_first_shared_preferences: SharedPreferences adapter (config-only key/value storage).
Sync strategies
local_first_periodic_strategy: Periodic data sync strategy for robust periodic fetches.local_first_websocket: WebSocket data sync strategy for live communication.
Backup & restore
local_first_firebase_backup: Firebase Storage backup provider (cross-platform).local_first_gdrive_backup: Google Drive backup provider (Android).local_first_icloud_backup: iCloud backup provider (iOS/macOS).
Status: early preview. The package is in active development (final stages) and the public API may change before the first stable release. Use the examples below as guidance for the intended design.
Why local_first?
Local-first strategy allows your remote devices to work independently of the network connection and, at the same time, allows those devices to seamlessly synchronize with the remote when the connection is available. This turns your application:
-
Always available and ready for usage: even with no network connection—networks can drop at any moment, even inside signal-shadow zones.
-
Fast synchronization between devices: synchronization flows continuously without requiring long, heavy processes.
-
Backend-agnostic: integrate it easily with any backend technology, even pre-existing ones.
-
Minimum boilerplate: all synchronization intelligence is handled by the package so you focus on your business logic.
Local-first principles
Local-first apps give users instant feedback by reading and writing to the device first, even with no network. The sync layer runs in the background: when connectivity returns, queued changes are pushed and remote updates are pulled. Your UI never blocks on the server; the storage delegate is the primary interface your repositories talk to.
Source of truth
During a session, the local database is the active source of truth. Remote systems are reconciled via pull/push cycles orchestrated by your sync strategy, and per-user namespacing works like internal and independent databases, keeping data domains isolated.

The events table is the canonical history. Every mutation becomes an event (event_id + created_at, both UUID v7-based), which is pushed upstream; downstream clients pull those events to converge on eventual consistency. Multiple sync strategies can run in parallel, and idempotency is enforced by event identifiers so duplicates are ignored, not reapplied.
The data tables store the current materialized state derived from that event stream. Storage plugins hydrate them from the normalized event history while remaining compatible with any existing schema, so you never need to drop or recreate tables—just replay the events and let the data tables stay responsive for local reads/writes.
Conflict resolution
Plan your data model to avoid hotspots: prefer append-only logs, split records so multiple writers don’t touch the same row, and keep timestamps in UTC. The best strategy is to design your model so conflicts simply cannot happen.
For example, the bundled counter_app demo feels like a single shared counter to the user, but it is implemented as the sum of independent per-user/device registers. Each namespace owns its own row, syncs independently, and the UI simply aggregates those values. Because no global row is rewritten, there is nothing to conflict against, and the sync layer never has to resolve concurrent writes.
If concurrent edits still happen, plug in resolution rules per repository—last-write-wins, timestamp comparison, or custom merge callbacks—to decide which state should prevail when syncing.
Conflict resolution modes
- Last-write-wins (LWW): pick the event with the latest
updated_at(or another monotonic field). - Timestamp comparison: prefer remote/local based on creation/update timestamps.
- Custom resolver: implement domain-specific merge logic (e.g., merge maps, sum counters, reconcile lists).
Example: registering LWW with onConflict when creating a repository:
final todoRepository = LocalFirstRepository<Todo>.create(
name: 'todo',
getId: (todo) => todo.id,
toJson: (todo) => todo.toJson(),
fromJson: Todo.fromJson,
onConflictEvent: (local, remote) {
final localUpdated = local.data.updatedAt;
final remoteUpdated = remote.data.updatedAt;
return remoteUpdated.isAfter(localUpdated) ? remote : local;
},
);
Server sync index
Keep a remote cursor per repository (commonly server_sequence or server_created_at) so pulls fetch only new/changed events. Store this cursor in config/meta storage and advance it after each successful pull; this keeps sync idempotent and efficient when talking to any backend API.
Example apps overview
The repository includes two complete demo apps:
- counter_app: A multi-user, namespaced counter with user profiles and session counters. Shows repositories talking to storage, config/meta reads/writes, and a sync strategy exchanging events with a backend.
- chat_app: A real-time chat application with rooms, messages, and user authentication. Demonstrates message pagination, conflict resolution, and dual sync strategy (WebSocket + Periodic).
Use them as blueprints for wiring your own models and strategies.
Running the examples
Each package ships a demo app. Choose the one you want to explore and run:
Storage adapters:
local_first/example(in-memory for dev)local_first_hive_storage/example(Hive storage)local_first_sqlite_storage/example(SQLite storage)local_first_shared_preferences/example(config-only storage)
Sync strategies:
local_first_periodic_strategy/example(periodic REST sync)local_first_websocket/example(real-time WebSocket sync)
From inside the chosen folder: flutter run.
Features
- Offline-first caching with automatic retry when connectivity is restored.
- Typed repositories for common CRUD flows.
- Conflict handling strategies (last-write-wins, timestamps, and custom resolution hooks).
- Background sync hooks for push/pull cycles.
- Encryption-ready storage layer by leveraging your chosen database/provider.
- Dev-friendly: simple configuration, verbose logging, and test utilities.
Installation
Add the core package and the adapters you need to your pubspec.yaml:
dependencies:
# Core package (required)
local_first: ^0.9.0
# Storage adapters (choose one or more)
local_first_hive_storage: ^0.3.0 # schema-less key/value storage
local_first_sqlite_storage: ^0.5.0 # structured tables with indexes
local_first_shared_preferences: ^0.1.2 # config-only key/value storage
# Sync strategies (choose one or more)
local_first_periodic_strategy: ^0.2.2 # periodic REST sync
local_first_websocket: ^0.3.1 # real-time WebSocket sync
# Backup providers (choose one or more)
local_first_firebase_backup: ^0.1.1 # Firebase Storage (cross-platform)
local_first_gdrive_backup: ^0.1.1 # Google Drive (Android)
local_first_icloud_backup: ^0.1.1 # iCloud (iOS/macOS)
Then install it with:
flutter pub get
Quick start
The API is evolving, but the intended flow looks like this:
import 'package:local_first/local_first.dart';
// 1) Describe your model (no mixin needed). LocalFirstEvent will wrap it with
// sync metadata. Keep your dates in UTC.
class Todo {
const Todo({
required this.id,
required this.title,
this.completed = false,
required this.updatedAt,
});
final String id;
final String title;
final bool completed;
final DateTime updatedAt;
JsonMap toJson() => {
'id': id,
'title': title,
'completed': completed,
'updated_at': updatedAt.toUtc().toIso8601String(),
};
factory Todo.fromJson(JsonMap json) => Todo(
id: json['id'] as String,
title: json['title'] as String,
completed: json['completed'] as bool? ?? false,
updatedAt: DateTime.parse(json['updated_at']).toUtc(),
);
// Last write wins.
static Todo resolveConflict(Todo local, Todo remote) =>
local.updatedAt.isAfter(remote.updatedAt) ? local : remote;
}
// 2) Create a repository for the model.
final todoRepository = LocalFirstRepository<Todo>.create(
name: 'todo',
getId: (todo) => todo.id,
toJson: (todo) => todo.toJson(),
fromJson: Todo.fromJson,
onConflictEvent: (local, remote) {
final resolved = Todo.resolveConflict(local.data, remote.data);
return identical(resolved, local.data) ? local : remote;
},
);
Future<void> main() async {
// 3) Wire up local storage plus your sync strategy.
final client = LocalFirstClient(
repositories: [todoRepository],
// Choose your adapter (add dependency and import it):
// import 'package:local_first_hive_storage/local_first_hive_storage.dart';
// import 'package:local_first_sqlite_storage/local_first_sqlite_storage.dart';
localStorage: HiveLocalFirstStorage(), // or SqliteLocalFirstStorage()
syncStrategies: [
// Provide your own strategy that implements DataSyncStrategy.
MyRestSyncStrategy(),
],
);
await client.initialize();
// 4) Use the repository as if you were online the whole time.
// LocalFirstEvent is created internally and keeps sync metadata immutable.
await todoRepository.upsert(
Todo(id: '1', title: 'Buy milk', updatedAt: DateTime.now().toUtc()),
needSync: true,
);
// served instantly from local cache
final todoStream = todoRepository.query().orderBy('title').watch();
// 5) Let your strategy push/pull, or trigger manually when it makes sense.
// await client.sync();
}
Choose your storage backend
- Hive: schema-less, fast key/value boxes. Add
local_first_hive_storageand useHiveLocalFirstStorage()(default in the basic example). - SQLite: structured tables with indexes for query filters/sorts. Add
local_first_sqlite_storageand useSqliteLocalFirstStorage(), providing a schema when creating repositories:
final todoRepository = LocalFirstRepository<Todo>.create(
name: 'todo',
getId: (todo) => todo.id,
toJson: (todo) => todo.toJson(),
fromJson: Todo.fromJson,
onConflict: Todo.resolveConflict,
schema: const {
'title': LocalFieldType.text,
'completed': LocalFieldType.boolean,
'updated_at': LocalFieldType.datetime,
},
);
final client = LocalFirstClient(
repositories: [todoRepository],
localStorage: SqliteLocalFirstStorage(),
syncStrategies: [MyRestSyncStrategy()],
);
Dropping local data
delete is a write: it is logged as an event and sent to the server. Sometimes
the device only has to let go of data the server already knows about, with
nothing to send. Two storage operations cover that, and mean the same on every
backend:
final storage = client.localStorage;
// A full re-sync of one repository: forget what came from the server, keep
// what this device wrote and has not sent yet, then pull again.
final dropped = await storage.deleteAllSynced('message');
// The writes that were still pending stay pending: the next sync cycle
// sends them, and the server refills everything else.
// Data that stopped being ours to hold — a conversation the account left.
// Nothing is queued for the remote: the server already knows.
await storage.deleteWhere('message', field: 'chat_id', value: chatId);
Both return how many items were removed, and both notify the watchers of the
repository. deleteAllSynced keeps every item that still has an event waiting
to be sent, together with its events, so an offline write is never lost to a
re-sync. deleteWhere matches a field declared in the repository's schema on
its own column; any other field is matched inside the stored payload, which
costs a scan — declare the fields you drop by.
To remove whole database files, and not rows, see
SqliteLocalFirstStorage.deleteDatabases.
Watching
watch() and watchChanges() are there to leave the caller on the newest
state, not to replay every state. In SqliteLocalFirstStorage and
InMemoryLocalFirstStorage:
- A watcher runs one read at a time. Writes that land while a read is in progress collapse into a single further read, so the last value a watcher emits is the current one.
- A row deleted while a watcher is reading does not break the read — which is
what
deleteWheredoes to a watcher of the rows it removes. - Watchers belong to the storage, not to a namespace: one registered before
useNamespacekeeps following whichever namespace is current and is re-emitted right after the switch, so a screen opened before a user change shows the new user's data without being rebuilt. The two storages behave alike here, so a test written over the in-memory one proves what the device will do.
HiveLocalFirstStorage re-reads on every box event and re-opens its boxes on a
namespace switch, so it keeps neither guarantee: a watcher registered before the
switch stops.
Example apps
Two complete Flutter apps demonstrate the local-first architecture:
- counter_app: Multi-user shared counter showcasing real-time sync with WebSocket + Periodic strategies
- chat_app: Real-time chat application with message pagination, user authentication, and dual sync strategy
# Clone and fetch deps
git clone https://github.com/rafaelsetragni/local_first.git
cd local_first
flutter pub get
# Run the counter example
cd counter_app
flutter pub get
flutter run
# Or run the chat example
cd chat_app
flutter pub get
flutter run
Migration guide
0.8.2 → 0.9.0
No changes are needed if you use the built-in storage backends (Hive, SQLite,
in-memory), and the storage adapters of this version require local_first
^0.9.0 — upgrade them together.
Only if you maintain a custom LocalFirstStorage, two required members were
added to the interface:
Future<int> deleteAllSynced(String tableName)— delete every item whose events are all synced, with those events, keep the items that still have an event waiting to be sent, and return how many items were deleted.Future<int> deleteWhere(String tableName, {required String field, required Object? value})— delete every item whosefieldequalsvalue, with their events, and return how many. Match a field of the repository's schema on its own column/index when your backend has one.
Both notify the repository's watchers when they removed something. A backend
that cannot tell a synced item from a pending one may return 0 and do nothing,
as long as you never offer a re-sync over it.
0.8.1 → 0.8.2
No changes are needed if you use the built-in storage backends (Hive, SQLite, in-memory) — the batch remote-event apply is just faster.
Only if you maintain a custom LocalFirstStorage, two members were added to
the interface:
Future<void> runInTransaction(Future<void> Function() action)— runactioninside a single transaction/batch when your backend supports it (commit once, defer notifications). Backends without a real transaction can simply=> action().getAllEventsgained an optionaldataId:getAllEvents(String tableName, {String? dataId}). When provided, return only that record's events (ideally via a storage-level filter such as a SQLWHERE).Stream<void> watchChanges(String repositoryName)— a broadcast stream that emits (with one initial tick on listen) whenever the repository changes, so callers can reload through their own read path. Wire it to your backend's native change feed (SQLite/Hive do), or returnconst Stream.empty()if your backend does not observe changes.
0.7.x → 0.8.0
⚠️ Event metadata field rename
All internal event metadata keys now use a _ prefix to prevent collision with entity fields (e.g. user.created_at no longer conflicts with the event's own _created_at).
| Before | After |
|---|---|
eventId |
_event_id |
repository |
_repository |
operation |
_operation |
createdAt |
_created_at |
data |
_data |
dataId |
_data_id |
syncStatus |
_sync_status |
lastEventId |
_last_event_id |
The Dart constants (LocalFirstEvent.kEventId, kSyncCreatedAt, etc.) remain unchanged — only the string values they hold have changed. If you access event fields exclusively through these constants, no code changes are needed. A one-time database migration may be required if you have existing persisted events.
Roadmap
XSimple chat application example at repository.XSimple global concurrent counter application example at repository.XImplement SQLite storage adapters via add-on packages.XImplement Hive storage adapters via add-on packages.XProvide Periodic REST and WebSocket sync strategies via add-on packages.XEnd-to-end sample app with authentication.XComprehensive docs and testing utilities (models now useLocalFirstModelmixin; full test coverage added).XBackup & restore via Firebase Storage, Google Drive, and iCloud add-on packages.Background sync helpers for Android/iOS via add-on packages.
Contributing
Contributions are welcome. See CONTRIBUTING.md for guidelines.
Support the Project 💰
Your contributions help us enhance and maintain our plugins. Donations are used to procure devices and equipment for testing compatibility across platforms and versions.
License
This project is available under the MIT License. See LICENSE for details.

