I spent three months adding offline sync to ctrodb — a client-side database I'd been working on for about a year before that. The database itself was straightforward: schema validation, queries, a reactive signal system. Couple thousand lines. Nothing wild.
Sync was different.
It took more code than the rest of the database combined. I rewrote the conflict resolver three times. I shipped a transport system I later threw out. I learned what "eventually consistent" actually means when you're the one who has to implement it.
This is what I learned.
npm: npm install ctrodb
GitHub: github.com/ctrotech-tutor/ctrodb
Docs: ctrodb.vercel.app/docs
The Hard Part Is Not the Data Transfer
When I started, I thought sync would be: get data from server, merge with local data, done.
The first version was exactly that. A push endpoint, a pull endpoint, a simple merge. It worked for the demo. Then I tried editing the same record on two devices at once.
The second device's push would silently overwrite the first device's changes. Data gone. No notification. No way to recover.
That's when I realized sync isn't about moving bytes. It's about knowing what changed, in what order, and what to do when two changes conflict.
Change Tracking: The Foundation
The sync engine is a plugin that hooks into the database's write pipeline. When you create, update, or delete a record, the plugin writes a change record to an internal change log.
interface SyncChangeRecord {
id: string
collection: string
recordId: string
type: "create" | "update" | "delete"
data: Record<string, unknown> | null
prevData: Record<string, unknown> | null
timestamp: string
status: "pending" | "syncing" | "committed" | "failed"
retries: number
errorMessage: string | null
createdAt: string
updatedAt: string
}
The change log write happens inside the same transaction as the main data write. If one fails, both fail. No orphaned changes.
The plugin hooks are onAfterCreate, onAfterUpdate, and onAfterDelete. Each one appends to the change log.
const plugin: CtroDBPlugin = {
name: "sync",
async onDatabaseInit(db) {
tracker = new ChangeTracker(db._getAdapter())
engine = new SyncEngine(db, config)
await engine.init()
},
async onAfterCreate(collection, record) {
await tracker.append("create", collection, record.id, record)
},
async onAfterUpdate(collection, id, record, oldRecord) {
await tracker.append("update", collection, id, record, oldRecord ?? null)
},
async onAfterDelete(collection, id, oldRecord) {
await tracker.append("delete", collection, id, null, oldRecord ?? null)
},
}
The plugin also registers store indexes — the change log is an IndexedDB object store with indexes on status and timestamp.
Pull: Cursor-Based Pagination
Pull requests ask the server for changes since the last sync. The actual implementation uses cursor-based pagination, not sequence numbers.
interface SyncTransport {
pull(options?: {
cursor?: string | null
collections?: string[]
batchSize?: number
signal?: AbortSignal
}): Promise<{
changes: SyncChangeRecord[]
cursor: string | null
hasMore: boolean
}>
}
The cursor is an opaque token — usually a timestamp or a record ID. The client sends cursor: null for the first pull (fetch everything) and the last cursor from the previous sync for subsequent pulls.
The server returns changes plus the next cursor. If hasMore is true, the client keeps pulling in a loop (up to 1000 pages, safety valve).
Push: Batch and Retry
Push collects all pending and failed changes, sends them in batches, and processes the server's response.
async function pushChanges() {
const pending = await tracker.getPending() // pending + failed, sorted by timestamp
if (pending.length === 0) return
const batch = pending.slice(0, pushBatchSize)
await tracker.markSyncing(batchIds)
try {
const result = await transport.push(batch, { signal })
for (const accepted of result.accepted) tracker.markCommitted(accepted.id, ...)
for (const conflict of result.conflicts) await resolveConflict(conflict)
for (const err of result.errors) await tracker.markFailed(err.id, err.error)
} catch {
// Mark back as pending on network failure
}
}
The server returns three lists: accepted (applied), conflicts (need resolution), errors (rejected). Each is handled differently.
Conflict Resolution: The Part I Got Wrong
My first conflict resolver used last-write-wins (strategy: "lww"). Whoever wrote last, their data survives. Simple. Wrong.
Consider this: Alice edits a note's title on her phone while offline. Bob edits the same note's body on his laptop, also offline. Both sync when they reconnect. With last-write-wins, whichever syncs second loses. Bob's body edit gets wiped because Alice's phone happened to flush its sync queue a few seconds later.
That's not Bob's fault. He changed different fields. The data should merge.
The built-in strategies are:
type ConflictStrategy = "lww" | "client-wins" | "server-wins" | "custom"
-
"lww"— last-write-wins by timestamp. Default. -
"client-wins"— local version always wins. -
"server-wins"— remote version always wins. -
"custom"— you provide aconflictResolverfunction.
For field-level merge, use the custom resolver:
const db = new Database({
name: "my-app",
schema: { ... },
plugins: [syncPlugin({
transport,
strategy: "custom",
conflictResolver: (conflict) => {
const merged = { ...conflict.local, ...conflict.remote }
return { resolution: "merged", merged }
},
})],
})
The conflict object gives you both versions and a list of conflicting fields:
interface SyncConflict {
changeId: string
recordId: string
collection: string
local: Record<string, unknown> | null
remote: Record<string, unknown> | null
localTimestamp: string
remoteTimestamp: string
fieldConflicts: string[]
}
The resolver returns a ConflictResolution:
type ConflictResolution = {
resolution: "local" | "remote" | "merged"
merged?: Record<string, unknown> | null
}
If you return "local", the server version is discarded. "remote" overwrites local. "merged" applies your merged object.
Transport: HTTP with a Single URL
The HTTP transport takes a single base URL, not separate push/pull URLs. It appends /push and /pull paths automatically.
const transport = new HttpTransport({
url: "https://api.myapp.com/sync",
headers: { Authorization: "Bearer ..." },
timeoutMs: 10000,
})
It implements connect(), disconnect(), isConnected(), push(), and pull(). Push sends a POST to /push, pull sends a POST (or GET) to /pull.
I added rate limiting detection — if the server returns 429, the transport parses the Retry-After header and surfaces it. The sync engine uses this to back off.
WebSocket transport is available for real-time push scenarios, but most apps don't need it. HTTP is simpler, stateless, and easier to debug.
What I'd Do Differently
Test with real offline scenarios earlier. My first tests used simulated networks with controlled latency. Real offline is messier — partial connectivity, brief flips between online and offline, race conditions on reconnect. I added a faulty transport helper that drops every nth request and randomizes delay. That caught bugs throttling never triggered.
Build devtools from day one. Debugging sync is hard because the state is distributed. The devtools — inspectSyncQueue(db), getSyncStats(db), retryFailedSync(db) — saved me more times than I can count. I added them halfway through. Should have been first.
import { inspectSyncQueue, retryFailedSync } from "ctrodb"
const queue = await inspectSyncQueue(db)
console.log(queue.stats)
// { total: 42, pending: 3, syncing: 0, committed: 38, failed: 1 }
await retryFailedSync(db) // marks failed items back to pending and triggers sync
Don't try to handle every edge case. I spent two weeks on a sync ordering dependency system — ensuring referenced records sync before their dependents. The database already handled this at the app level. I removed it and sync worked fine.
Trade-offs I'm Still Thinking About
The current design trades real-time collaboration for simplicity. Two users on the same document won't see each other's changes until they sync. True real-time would need a CRDT or OT layer.
But for most apps — todos, notes, offline-first dashboards — the sync-every-few-seconds model works. The complexity of CRDTs isn't worth it until you're building Google Docs.
npm: npm install ctrodb
Sync code: github.com/ctrotech-tutor/ctrodb (in src/sync/)
Sync docs: ctrodb.vercel.app/docs/sync/overview
Top comments (0)