Offline-first sync framework for Dexie.js with REST API support
- ✅ REST API Sync - Works with any REST backend (no special database required)
- ✅ Offline Queue - Automatically queues changes when offline
- ✅ Conflict Resolution - Built-in strategies (LWW, server-wins, client-wins, custom)
- ✅ Multi-tab Safe - Leader election ensures single-tab sync
- ✅ Smart Retry - Exponential backoff with dead letter queue
- ✅ TypeScript First - Full type safety and inference
- ✅ Observability - Metrics, events, and health checks
- ✅ Zero Backend Changes - Works with existing REST APIs
npm install @dexie-kit/sync dexieimport Dexie from 'dexie';
import { startSync, defineRoutes } from '@dexie-kit/sync';
// 1. Define your database
const db = new Dexie('myapp');
db.version(1).stores({
posts: '++id, title, updatedAt',
comments: '++id, postId, content, updatedAt',
});
// 2. Configure sync routes
const routes = defineRoutes({
posts: {
push: {
create: {
method: 'POST',
url: '/api/posts',
body: (item) => item,
},
update: {
method: 'PUT',
url: (item) => `/api/posts/${item.id}`,
body: (item) => item,
},
delete: {
method: 'DELETE',
url: (item) => `/api/posts/${item.id}`,
},
},
pull: {
method: 'GET',
url: '/api/posts',
query: async (ctx) => ({
updated_after: await ctx.getCheckpoint('pull:posts') || 0,
}),
mapResponse: (response) => response.data,
onComplete: async (response, ctx) => {
const latest = Math.max(
...response.data.map(p => new Date(p.updatedAt).getTime())
);
await ctx.setCheckpoint('pull:posts', latest);
},
},
},
});
// 3. Start sync
const syncEngine = startSync(db, {
baseUrl: 'https://api.example.com',
auth: {
getHeaders: async () => ({
'Authorization': `Bearer ${await getToken()}`,
}),
},
routes,
conflicts: {
policy: 'server-wins',
},
sync: {
interval: 30000, // Sync every 30 seconds
onOnline: true, // Sync when coming online
},
});
// 4. Use your app normally
await db.posts.add({ title: 'Hello World' });
// Sync happens automatically!
await syncEngine.start();All local changes are tracked in an outbox queue. When online, changes are pushed to the server.
Track the last sync point for each table to enable delta sync (only fetch what changed).
When the same record is modified both locally and on the server, conflicts are resolved using:
- server-wins - Server version always wins (safest)
- client-wins - Client version always wins (use with caution)
- lww - Last-write-wins based on timestamps
- custom - Your own resolution function
Only one browser tab performs sync to avoid race conditions. Uses BroadcastChannel API.
Starts sync for a Dexie database.
Parameters:
db: Dexie- Your Dexie database instanceconfig: SyncConfig- Sync configuration
Returns: SyncEngine
// Lifecycle
await syncEngine.start();
await syncEngine.stop();
await syncEngine.pause();
await syncEngine.resume();
// Manual sync
const result = await syncEngine.sync();
await syncEngine.push();
await syncEngine.pull();
await syncEngine.syncTable('posts');
// Status
const status = syncEngine.getStatus();
const isOnline = syncEngine.isOnline();
const isSyncing = syncEngine.isSyncing();
const depth = await syncEngine.getQueueDepth();
// Events
syncEngine.on('sync-complete', (result) => {
console.log('Synced!', result);
});
// Advanced
const health = await syncEngine.healthCheck();
const deadLetters = await syncEngine.getDeadLetters();
await syncEngine.retryDeadLetter(id);sync-start- Sync startedsync-complete- Sync completedsync-error- Sync failedpush-start- Push startedpush-complete- Push completedpush-error- Push failedpull-start- Pull startedpull-complete- Pull completedpull-error- Pull failedconflict- Conflict detectedonline- Network onlineoffline- Network offlinemetrics- Metrics update
startSync(db, {
baseUrl: 'https://api.example.com',
routes: {
// ... route config
},
auth: {
getHeaders: async () => ({
'Authorization': `Bearer ${token}`,
}),
onAuthError: async (error) => {
if (error.status === 401) {
await refreshToken();
}
},
maxAuthRetries: 3,
},
sync: {
interval: 30000,
onOnline: true,
onVisibilityChange: true,
push: {
batchSize: 10,
concurrency: 3,
},
pull: {
pageSize: 100,
maxPages: 10,
},
},
conflicts: {
policy: 'lww',
onConflict: async (conflict) => {
// Custom resolution
return conflict.remote;
},
},
errors: {
maxRetries: 5,
retryDelay: (attempt) => Math.min(1000 * Math.pow(2, attempt), 60000),
},
observability: {
enabled: true,
metricsInterval: 30000,
onMetrics: (metrics) => {
console.log('Metrics:', metrics);
},
},
});Your REST API needs:
- CRUD endpoints for each resource
- Timestamp field (e.g.,
updatedAt) for delta queries - Timestamp filtering (e.g.,
?updated_after=1234567890) - Standard HTTP status codes
app.get('/api/posts', async (req, res) => {
const { updated_after = '0' } = req.query;
const posts = await db.posts.findMany({
where: {
updatedAt: { gt: new Date(Number(updated_after)) }
},
orderBy: { updatedAt: 'asc' }
});
res.json({ data: posts });
});
app.post('/api/posts', async (req, res) => {
const post = await db.posts.create({
data: { ...req.body, updatedAt: new Date() }
});
res.status(201).json(post);
});
app.put('/api/posts/:id', async (req, res) => {
const post = await db.posts.update({
where: { id: req.params.id },
data: { ...req.body, updatedAt: new Date() }
});
res.json(post);
});
app.delete('/api/posts/:id', async (req, res) => {
await db.posts.delete({ where: { id: req.params.id } });
res.status(204).send();
});See the examples directory for complete working examples:
- React + Vite
- Next.js
- Express backend
- FastAPI backend
MIT © Abdussamad Bello
Contributions welcome! Please read the contributing guide.
- Service Worker integration
- WebSocket real-time sync plugin
- CRDT support for collaborative editing
- React hooks for sync state
- Vue composables
- Svelte stores