Offline Support
Koolbase database works offline out of the box. Reads load instantly from local cache, writes are queued and synced automatically when the network is restored.
How it works
The SDK uses a local Drift database on the device to cache query results and queue writes. No configuration needed — offline support is enabled automatically when you initialize the SDK.
ReadsCache-first — returns local data instantly, then refreshes from network in the backgroundWritesOptimistic — saves locally first, syncs to server when onlineAuto-syncTriggered automatically when network connectivity is restoredManual syncCall syncPendingWrites() at any point to force a syncCache-first reads
Queries are stale-while-revalidate, in every SDK: a cache hit returns immediately with isFromCache set, and a background refresh updates the cache behind it — so a repeat of the same query serves the refreshed result. A cache miss awaits the network. The consequence worth designing for: after reconnecting, the first query of something you saw offline may still show the cached copy; the fresh data is one query behind. Background refresh failures are deliberately silent — the cached result was already returned.
final result = await Koolbase.db
.collection('posts')
.get();
if (result.isFromCache) {
// Data loaded from local storage — network refresh in progress
print('Showing cached data');
} else {
// Fresh data from server
print('Showing live data');
}
for (final post in result.records) {
print(post.data['title']);
}Background refresh stream
@override
void initState() {
super.initState();
// Listen for fresh data after background refresh
Koolbase.db.collection('posts').stream.listen((result) {
setState(() => _posts = result.records);
});
}Offline writes
Inserts, updates, and deletes work the same way whether you're online or offline. If the network is unavailable, the write is queued locally and synced automatically when connectivity is restored.
// Works online and offline — no change to your code
final post = await Koolbase.db.insert(
collection: 'posts',
data: {
'title': 'Written offline',
'body': 'This will sync when back online',
},
);
// If offline, returns an optimistic record immediately
print(post.id); // temporary local ID until syncedOptimistic updates
Editing offline
An update or delete is queued only if the SDK knows what the record looked like when the change was made — which it does if the record has been read on this device, through a query, a doc().get(), or because it was created here.
Without that, replaying the change would mean applying it blindly: whatever else happened to the record while the device was away is overwritten, silently. So the write is refused instead, with KoolbaseOfflineBaselineUnavailableException.
// Queued offline, applied on reconnect
await Koolbase.db.doc(id).update({'title': 'Corrected'});
await Koolbase.db.doc(id).delete();Conflicts
When a queued write replays, the server applies it only if the record still carries the revision the change was based on. If something changed it meanwhile — another device, another user, a Function — the write is refused and becomes a conflict: held, not applied, and not lost. Conflicts survive restarts.
final conflicts = await Koolbase.db.conflicts();
for (final c in conflicts) {
c.local; // the change the user made
c.server; // the record as the server holds it now
c.divergentFields; // where they disagree
await c.resolveWithLocal(); // reapply the user's change
await c.resolveWithServer(); // keep the server's version
await c.resolveWithMerge({...}); // something composed from both
await c.abandon(); // drop it, neither side wins
}
// Or watch, for a badge or a review screen
Koolbase.db.watchConflicts().listen(...);Conflicts do not expire
conflicts() accumulates them in local storage indefinitely, invisible to the user, with the changes they hold never applied. If you support offline editing, surface them somewhere. Expiring them automatically would hide the problem while quietly losing the work.Syncing pending writes
The SDK auto-syncs when network connectivity is restored. You can also trigger a sync manually at any point — for example, on app resume or after a user action.
// Manual sync — call at any point
await Koolbase.db.syncPendingWrites();// Sync on app resume
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
Koolbase.db.syncPendingWrites();
}
}Observing sync state
The queue is the same kind of durable state as conflicts, one step earlier in the lifecycle: a conflict is a write the server refused; a pending write is one the server has not seen yet. Both deserve surfacing — a queue nobody can see accumulates invisibly, and the changes it holds feel saved to the user while existing only on this device. pendingWrites() returns them oldest first, as a deliberate projection: operation, collection, record id, the changed data, when it was queued, and how many send attempts have failed. Replay internals are not exposed, and a delete carries no data.
The moment this API exists for is logout. The queue is per-user and survives sign-out by design, so a user leaving with pending writes walks away believing their edits saved — and they sync whenever that user next signs in on this device, which may be never. Check before signing out, and warn.
// Before logout: anything unsynced?
final pending = await Koolbase.db.pendingWrites();
if (pending.isNotEmpty) {
// "You have 3 unsynced changes. Sync now, or they wait
// until you next sign in on this device."
await Koolbase.db.syncPendingWrites();
}
// Live, for a sync badge
Koolbase.db.watchPendingWrites().listen((writes) {
syncBadge.count = writes.length;
});Signed out, the question has no answer
pendingWrites() and conflicts() throw when no user is signed in, rather than returning an empty list. Per-user state has no answer without a user — and a silent empty is indistinguishable from "all synced", which is exactly the lie it would tell. Check pending work before signing out.When a write fails
A failed replay has three outcomes, and they are deliberately different. Nothing is ever dropped: a write discarded after some number of attempts is discarded silently, and the user is never told.
Local storage
Offline state persists across app restarts, in the app's private storage — not accessible to other apps. The engine differs by platform, and the behaviour above does not: Flutter uses Drift (SQLite), where moving a write between queue and conflicts is a database transaction. React Native uses AsyncStorage and the browser uses IndexedDB, where pending writes and conflicts share one value so that same move is a single atomic write, and every mutation is serialised through a per-user lock. In a browser that lock also spans tabs, through the Web Locks API, so two tabs cannot replay the same queued write.
Current limitations
Offline edits need a prior read
An update or delete can only be queued for a record this device has read. Without knowing what the change was based on, replaying it would overwrite whatever happened meanwhile.
Realtime offline
WebSocket subscriptions require network. Offline reads use cached data only.
Nested populate offline
Populate queries use network data only — not resolved from local cache.