DEV Community

Cover image for Offline Sync in the Browser Without a Framework
Odejobi Abiola Samuel
Odejobi Abiola Samuel

Posted on

Offline Sync in the Browser Without a Framework

I've been building apps with IndexedDB for years. The local part works fine — store data, query it, show it on screen. The hard part is keeping that data in sync with a server when the network comes and goes.

Most tutorials show you how to build an offline app with a framework. Firebase, RxDB, WatermelonDB. Those work, but they bring their own abstractions, their own sync protocols, their own opinions.

I wanted something simpler. A database with a sync API that doesn't dictate how my backend works.

Here's the setup I landed on.

npm: npm install ctrodb
Docs: ctrodb.vercel.app/docs/sync/overview

What We're Building

A notes app that works offline. Create and edit notes on the train, in a tunnel, on a plane. When the network comes back, everything syncs automatically.

The database is ctrodb (zero-dependency, browser-based). The backend is anything that speaks HTTP.

Step 1: Database Setup

import { Database, syncPlugin, HttpTransport } from "ctrodb"

const db = new Database({
  name: "notes-app",
  schema: {
    version: 1,
    collections: {
      notes: {
        fields: {
          title: { type: "string", required: true },
          body: { type: "string" },
          updatedAt: { type: "string", default: () => new Date().toISOString() },
        },
        indexes: [{ field: "updatedAt" }],
      },
    },
  },
})

await db.connect()
Enter fullscreen mode Exit fullscreen mode

Every collection you want to sync needs a timestamp field. The sync engine uses it to order changes and detect conflicts.

Plugins are passed in the Database constructor via plugins array:

const transport = new HttpTransport({
  url: "https://api.myapp.com/sync",
})

const db = new Database({
  name: "notes-app",
  schema: { ... },
  plugins: [syncPlugin({ transport })],
})

await db.connect()
Enter fullscreen mode Exit fullscreen mode

The transport takes a single base URL and appends /push and /pull automatically. The sync plugin hooks into every write operation and records it in the change log.

The plugin exposes devtools that take the database instance as their first argument:

import { inspectSyncQueue, retryFailedSync, getSyncStats } from "ctrodb"

const queue = await inspectSyncQueue(db)
console.log(queue.stats)
// { total: 12, pending: 3, syncing: 0, committed: 9, failed: 0 }

const stats = await getSyncStats(db)
console.log(stats.pending) // 3

await retryFailedSync(db)
Enter fullscreen mode Exit fullscreen mode

Step 3: The Server Handler

The server needs two endpoints. Pull returns changes (cursor-based). Push accepts incoming changes.

app.post("/sync/pull", async (req, res) => {
  const { cursor } = req.body
  const changes = await getChangesSince(cursor)
  const nextCursor = changes.length > 0
    ? changes[changes.length - 1].timestamp
    : cursor
  res.json({ changes, cursor: nextCursor, hasMore: false })
})

app.post("/sync/push", async (req, res) => {
  const { changes } = req.body
  const accepted = []
  const conflicts = []
  const errors = []

  for (const change of changes) {
    try {
      const existing = await findRecord(change.recordId)
      if (existing && existing.updatedAt > change.timestamp) {
        conflicts.push({ changeId: change.id, local: existing, remote: change.data })
      } else {
        await upsertRecord(change)
        accepted.push({ id: change.id, serverTimestamp: new Date().toISOString() })
      }
    } catch (e) {
      errors.push({ id: change.id, error: e.message })
    }
  }

  res.json({ accepted, conflicts, errors })
})
Enter fullscreen mode Exit fullscreen mode

The push response has three arrays. The sync engine handles each differently — accepted changes are marked committed, conflicts are passed to the resolver, errors are marked for retry.

Step 4: Trigger Sync

Trigger sync through the database instance. It pushes pending changes first, then pulls remote changes.

// On app start
await db.sync()

// After writing data
async function createNote(data) {
  const note = await db.collection("notes").create(data)
  await db.sync()
  return note
}

// Background sync every 30 seconds
setInterval(async () => {
  if (navigator.onLine) {
    await db.sync()
  }
}, 30000)
Enter fullscreen mode Exit fullscreen mode

Auto-sync is available in the plugin config:

const db = new Database({
  name: "notes-app",
  schema: { ... },
  plugins: [syncPlugin({
    transport,
    autoSync: { intervalMs: 30000, debounceMs: 500 },
  })],
})
Enter fullscreen mode Exit fullscreen mode

With auto-sync, the engine syncs on interval and after writes (debounced). You don't need to call db.sync() manually unless you want immediate sync.

Step 5: React Hooks

import { useSyncStatus } from "ctrodb/react"

function SyncIndicator() {
  const { isSyncing, pendingChanges, lastSyncAt } = useSyncStatus()

  if (isSyncing) return <span>Syncing...</span>
  if (pendingChanges > 0) return <span>{pendingChanges} changes pending</span>
  return <span>All changes saved</span>
}
Enter fullscreen mode Exit fullscreen mode

For manual sync control:

import { useSync } from "ctrodb/react"

function SyncButton() {
  const { sync, status } = useSync()
  return (
    <button onClick={sync} disabled={status.isSyncing}>
      {status.isSyncing ? "Syncing..." : "Sync now"}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

What This Setup Doesn't Do

This is a simple push-pull model. It works for single-user-offline — one person editing on their phone and laptop. It doesn't handle:

  • Real-time collaboration. Two users editing the same doc won't see changes until they sync. For that, you'd need WebSocket push or a CRDT layer.
  • Automatic field-level merge. The built-in strategies (lww, client-wins, server-wins) work at the record level. For field-level merge, you need a custom conflictResolver.
  • Offline auth token refresh. If your auth token expires while offline, the next sync will fail. Handle token refresh before the sync call or in the transport's headers callback.

For a notes app, todo list, or dashboard, the simple model is enough. You add complexity where you need it.

Links

Top comments (0)