Offline overview

Offline mode in FFDB is for apps that must keep working through flaky or absent connectivity. You provide a local database adapter, optionally a network adapter, and the client handles queued writes plus pull and push synchronization.

Why use offline mode

Choose offline mode when at least one of these is true:

  • Name
    Your users lose network often
    Description

    Mobile, desktop, and field apps need read/write continuity while disconnected.

  • Name
    You want local-first responsiveness
    Description

    Reads from local storage can feel instant while remote sync runs in the background.

  • Name
    You need reliable write delivery
    Description

    Mutation queueing allows writes to flush after connectivity returns.

Interface surfaces

The main contracts you wire are the offline adapter, network adapter, and sync status.

type OfflineAdapter = {
  execute(sql: string, params?: unknown[]): Promise<{ rows: Record<string, unknown>[] }> | { rows: Record<string, unknown>[] }
  close?(): Promise<void> | void
}

type NetworkAdapter = {
  isOnline(): boolean | Promise<boolean>
  subscribe(listener: (online: boolean) => void): () => void
}

type OfflineConfig = {
  adapter: OfflineAdapter
  network?: NetworkAdapter
  tables?: string[]
  skipTables?: string[]
  syncOnConnect?: boolean
  syncInterval?: number
  syncOnReconnect?: boolean
  syncOnFocus?: boolean
  maxRowsPerTable?: number
  pageSize?: number
}

type SyncStatus = {
  isSyncing: boolean
  isOnline: boolean
  lastSyncedAt: number | null
  pendingMutations: number
  error: Error | null
}

End-to-end setup flow

This pattern is the baseline for browser or desktop apps with local SQLite support.

import { createClient } from 'ffdb-client'
import type { Database } from './ffdb.types'

const offlineAdapter = {
  async execute(sql: string, params: unknown[] = []) {
    // Replace with your environment-specific SQLite execution.
    const rows = await runSQLiteQuery(sql, params)
    return { rows }
  },
}

const { db, sync } = await createClient<Database>({
  config: {
    apiUrl: import.meta.env.VITE_FFDB_API_URL,
  },
  offline: {
    adapter: offlineAdapter,
    syncOnConnect: true,
    syncOnReconnect: true,
    syncOnFocus: true,
    syncInterval: 30_000,
    maxRowsPerTable: 10_000,
    pageSize: 500,
  },
})

await db
  .insertInto('task')
  .values({ id: crypto.randomUUID(), title: 'Field note', completed: 0 })
  .execute()

if (sync) {
  await sync.run()
  console.log(sync.status)
}

How sync flows in practice

  1. Local writes are recorded while offline.
  2. When online, sync pushes pending mutations.
  3. Sync then pulls fresh table data and updates local cache.
  4. Subscribers can react to data/status changes.

This sequence is why sync helpers expose methods such as run, push, pull, waitForIdle, and status.

Configuration tradeoffs

  • Name
    tables
    Description

    Use when only specific tables should be hydrated locally. Good for least-data sync strategies.

  • Name
    skipTables
    Description

    Use to exclude large or low-value tables from local sync.

  • Name
    syncInterval
    Description

    Improves freshness, but increases battery/network usage in mobile contexts.

  • Name
    syncOnFocus
    Description

    Good for browser UX. Avoid if focus churn causes too many background syncs.

  • Name
    maxRowsPerTable and pageSize
    Description

    Balance initial sync speed vs memory/network pressure.

Monitoring sync status

if (sync) {
  const unsubscribe = sync.subscribe(() => {
    const { isSyncing, isOnline, pendingMutations, error } = sync.status
    console.log({ isSyncing, isOnline, pendingMutations, error })
  })

  await sync.waitForIdle(10_000)
  unsubscribe()
}

Next pages

  1. Local cache
  2. Sync lifecycle
  3. Mutation queue

Was this page helpful?