DEV Community

Cover image for I Built a Sync Engine for a 0-Dep Client-Side Database, Here's What I Learned
Odejobi Abiola Samuel
Odejobi Abiola Samuel

Posted on

I Built a Sync Engine for a 0-Dep Client-Side Database, Here's What I Learned

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
}
Enter fullscreen mode Exit fullscreen mode

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)
  },
}
Enter fullscreen mode Exit fullscreen mode

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
  }>
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
  • "lww" — last-write-wins by timestamp. Default.
  • "client-wins" — local version always wins.
  • "server-wins" — remote version always wins.
  • "custom" — you provide a conflictResolver function.

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 }
    },
  })],
})
Enter fullscreen mode Exit fullscreen mode

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[]
}
Enter fullscreen mode Exit fullscreen mode

The resolver returns a ConflictResolution:

type ConflictResolution = {
  resolution: "local" | "remote" | "merged"
  merged?: Record<string, unknown> | null
}
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)