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()
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()
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)
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 })
})
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)
Auto-sync is available in the plugin config:
const db = new Database({
name: "notes-app",
schema: { ... },
plugins: [syncPlugin({
transport,
autoSync: { intervalMs: 30000, debounceMs: 500 },
})],
})
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>
}
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>
)
}
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
- npm:
npm install ctrodb - GitHub: github.com/ctrotech-tutor/ctrodb
- Sync docs: ctrodb.vercel.app/docs/sync/overview
- React hooks: ctrodb.vercel.app/docs/react/setup
- Demo app: github.com/ctrotech-tutor/ctrodb (examples/react-sync/)
Top comments (0)