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 background
WritesOptimistic — saves locally first, syncs to server when online
Auto-syncTriggered automatically when network connectivity is restored
Manual syncCall syncPendingWrites() at any point to force a sync

Cache-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.

lib/posts_screen.dart
Dart
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

Listen to the collection stream to receive updates when the background network refresh completes:
lib/posts_screen.dart
Dart
@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.

lib/posts_screen.dart
Dart
// 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 synced

Optimistic updates

When offline, the SDK generates a temporary local ID for the new record. Once synced, the server assigns a permanent ID. Design your UI to handle this gracefully — for example, avoid relying on the ID for navigation until after sync.

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.

lib/edit_screen.dart
Dart
// 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.

lib/conflicts_screen.dart
Dart
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

An app that never reads 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.

lib/main.dart
Dart
// Manual sync — call at any point
await Koolbase.db.syncPendingWrites();
lib/main.dart
Dart
// 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.

lib/settings_screen.dart
Dart
// 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.

Conflict — the record movedHeld for a decision, never retried automatically. Retrying cannot help: the server will keep refusing until someone chooses. Read from conflicts(), resolve or abandon.
Rejected — the server refused outrightValidation failure, permission denial, missing record, unique collision. Terminal: the same request meets the same decision. Held with the server’s message, alongside conflicts, marked rejected.
Retryable — the network or the server falteredUnreachable network, 5xx, rate limit. Stays pending and retries on every sync, indefinitely. A retried insert cannot duplicate: it carries an idempotency key, and the server returns the original record for a repeat.
Special caseA 404 on a queued delete is satisfied, not failed — the record is gone, which is what the write was asking for.

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.

Storage engineFlutter: Drift (SQLite) · React Native: AsyncStorage · Web: IndexedDB
Cache scopePer user — no cross-user data leakage
PersistenceSurvives app restarts
Cache invalidationAutomatic after successful write or sync

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.