offline-sync-lite
v0.4.2
Published
Lightweight offline-first sync SDK for browser clients using IndexedDB with encryption, multi-tab coordination, schema migrations, and conflict resolution.
Downloads
523
Maintainers
Readme
offline-sync-lite
Lightweight offline-first sync SDK for Next.js. Enables seamless data synchronization on unstable networks using IndexedDB for persistence. Zero heavy dependencies, TypeScript-free, browser-native.
- 📦 Tiny: 43 KB minified (zero external deps, native ESM)
- 🔄 Offline-first: Queue operations locally, replay when online
- 🔒 At-Rest Encryption: Web Crypto AES-GCM 256-bit envelope encryption protects local records & queue
- 🛡️ Poison Pill Protection & DLQ: Quarantines permanent 4xx failures to prevent infinite replay loops
- 📑 Multi-Tab Concurrency: Web Locks API prevents double-sync races; BroadcastChannel shares live tab updates
- 👥 Multi-User Isolation: Partitioned IndexedDB databases, dedicated locks, and seamless account switching
- ⏰ Clock Skew Mitigation: Passive NTP-lite server time calibration and monotonic hybrid logical clocks
- 🧩 Field-Level Conflict Merging: Granular property-level merging and deep object merges without data loss
- 🛠️ Schema Versioning & Migrations: Sequential record & mutation transforms and server schema drift protection
- ⚖️ Optimistic Concurrency Control (OCC): Baseline timestamp preservation in mutation payloads & coalescing for reliable 409 conflict detection
- 📊 Metrics & Observability: Sync success rate, latency, retry count, queued ops, DLQ count
- ⚡ Auto-sync & Mutation Control: Immediate auto-sync (
syncOnMutation), periodic intervals, and online reconnect - 🎯 Ergonomic API: Designed for React & Next.js client components
Installation
npm install offline-sync-liteQuick Start
Next.js Client Component
"use client";
import { createSyncClient } from "offline-sync-lite";
import { useEffect, useState } from "react";
export default function TasksPage() {
const [tasks, setTasks] = useState([]);
const [metrics, setMetrics] = useState(null);
const client = createSyncClient({
apiUrl: "/api", // Base URL for REST endpoints
resourceName: "tasks", // Resource collection name
syncIntervalMs: 10000, // Auto-sync every 10s (optional)
syncOnMutation: true, // Auto-sync immediately on create/update/remove when online (default: true)
maxRetries: 3, // Retry failed ops up to 3 times (transient 5xx/network errors)
maxPermanentFailures: 5, // Quarantine non-retryable 4xx ops after 5 cycles (default: 5)
backoffBaseMs: 500, // Exponential backoff base (500ms)
conflictResolver: undefined, // Optional custom conflict resolver
onEvent: (evt) => {
// Optional event listener
if (evt.type === "sync_done") {
console.log("Sync completed:", evt);
}
if (evt.type === "op_dead_letter") {
console.warn("Op quarantined to Dead-Letter Queue:", evt);
}
},
});
useEffect(() => {
// Subscribe to local changes
const unsub = client.subscribe((items) => {
setTasks(items);
client.getMetrics().then(setMetrics);
});
client.startAutoSync();
client.list().then(setTasks);
return () => {
client.stopAutoSync();
unsub();
};
}, []);
return (
<div>
<h1>Tasks</h1>
{metrics && (
<div style={{ fontSize: "0.85rem", color: "#666" }}>
Sync: {(metrics.syncSuccessRate * 100).toFixed(0)}% | Queue:{" "}
{metrics.queuedOpsCount} | Conflicts: {metrics.conflictsDetected}
</div>
)}
<button
onClick={() =>
client.create({ id: crypto.randomUUID(), data: { title: "New" } })
}
>
Add
</button>
<ul>
{tasks.map((t) => (
<li key={t.id}>
{t.data?.title}
<button onClick={() => client.remove(t.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}API Reference
createSyncClient(config)
Returns a client instance with the following methods:
create(doc): Promise<record>
Create a new record locally and queue for sync.
await client.create({
id: "task-1",
data: { title: "Build an app", status: "open" },
});update(id, patch): Promise<record>
Update an existing record. The patch is merged into local data.
await client.update("task-1", { status: "in-progress" });remove(id): Promise<boolean>
Delete a record locally and queue deletion.
await client.remove("task-1");get(id), list(): Read from local IndexedDB.
const record = await client.get("task-1");
const allRecords = await client.list();subscribe(fn): unsubscribe
Listen to local changes (create/update/remove/pull).
const unsub = client.subscribe((items) => console.log(items));
unsub(); // Stop listeningsyncNow(): Promise<{ok, pushedOps, failedOps, appliedUpdates, durationMs}>
Manually trigger sync: push queued ops, pull remote updates, resolve conflicts.
pauseSync() / resumeSync()
NEW! Temporarily pause and resume sync operations.
// Pause sync (stops auto-sync, blocks syncNow)
client.pauseSync();
// Resume sync (restarts auto-sync if configured, triggers immediate sync)
client.resumeSync();cache.prune([resourceName], maxRecords): Promise<{pruned, kept}>
NEW! Manually prune oldest records in local IndexedDB to prevent cache bloat.
// Keep only the 500 newest tasks (deletes oldest evictable records)
const result = await client.cache.prune(500);
// Or in multi-resource client:
const result = await client.cache.prune("tasks", 500);
console.log(`Pruned ${result.pruned} records, ${result.kept} remaining`);cache.clear([resourceName]): Promise<{cleared}>
NEW! Wipe cached records for a resource while keeping pending unsynced operations intact.
await client.cache.clear();
// Or in multi-resource client:
await client.cache.clear("tasks");getDeadLetterOps(): Promise<Array>
NEW! Retrieve quarantined operations that failed permanently due to non-retryable errors (e.g., 400, 403, 422).
const deadOps = await client.getDeadLetterOps();
console.log(`Quarantined operations:`, deadOps);retryDeadLetterOp(opKey): Promise
NEW! Clear an operation's dead status and reset its permanent failure count so it can be re-attempted.
await client.retryDeadLetterOp(opKey);discardDeadLetterOps(): Promise<{ discarded: number }>
NEW! Permanently purge all quarantined dead-letter operations from IndexedDB.
const result = await client.discardDeadLetterOps();
console.log(`Purged ${result.discarded} dead operations`);startAutoSync() / stopAutoSync()
Enable/disable periodic syncing + online event detection.
destroy()
NEW! Clean up timers, event listeners, and close cross-tab BroadcastChannel instances. Call this when tearing down a client or unmounting components.
client.destroy();switchUser(newUserId, [options]): Promise<{ userId, dbName }>
NEW! Dynamically transition active client context to a new user account without full page reload. Halts in-flight sync, closes previous IndexedDB connections, re-initializes storage for the new tenant partition, updates UI subscribers, and triggers a fresh sync loop.
// Switch to user B (preserving user A's offline records in their partitioned DB)
await client.switchUser("user_456");
// Switch to user B and purge user A's database from disk
await client.switchUser("user_456", { purgeOldUser: true });
// Switch with user-specific encryption passphrase
await client.switchUser("user_456", {
encryption: { passphrase: "user-456-secret-passphrase" },
});logout([options]): Promise
NEW! Safely log out the current user: halts auto-sync, tears down multi-tab coordinator, closes IndexedDB connections, clears active encryption keys, and optionally wipes the user's local database.
// Logout and delete tenant database from browser storage (default)
await client.logout({ purgeData: true });
// Logout but preserve cached records for offline re-login
await client.logout({ purgeData: false });purge(): Promise<{ deleted: boolean, dbName: string }>
NEW! Completely purge the active user's partitioned IndexedDB database from the browser.
await client.purge();getClockOffset(): number
NEW! Returns the current estimated server time offset in milliseconds (estimatedServerTime - localDeviceTime).
const offsetMs = client.getClockOffset();
console.log(`Device clock is skewed by ${offsetMs}ms relative to server`);setClockOffset(offsetMs): Promise
NEW! Manually set or calibrate the server time offset in milliseconds. Persists in IndexedDB metadata storage across app restarts.
await client.setClockOffset(2500); // calibrate clock 2.5s aheadgetSchemaVersion(): Promise
NEW! Returns the current persisted domain schema version for the active tenant database.
const version = await client.getSchemaVersion(); // e.g. 2migrate(targetVersion): Promise<{ migrated, fromVersion, toVersion, recordsMigrated, opsMigrated }>
NEW! Manually trigger an on-demand schema migration up to targetVersion.
const result = await client.migrate(3);
console.log(`Migrated ${result.recordsMigrated} records to schema v3`);getMetrics(): Promise<metrics>
{
syncSuccessRate: 0.95, // (0–1) success rate
avgSyncLatencyMs: 245, // Average duration
retryCount: 2, // Total retries
queuedOpsCount: 3, // Active pending ops
deadLetteredOpsCount: 1, // Quarantined ops in DLQ
conflictsDetected: 1 // Total conflicts
}Multi-User Isolation & Account Switching
On shared workstations or multi-account applications, storing all data in a single global database risks severe data leakage and cross-tenant synchronization bugs. offline-sync-lite provides complete architectural isolation:
const client = createSyncClient({
apiUrl: "/api",
resourceName: "documents",
userId: "user_123", // Automatically isolates IndexedDB, Web Locks, and BroadcastChannels
encryption: {
passphrase: "user-123-master-passphrase",
},
});
console.log(client.userId); // "user_123"
console.log(client.dbName); // "offline-sync-lite:user_123"Key Security & Isolation Guarantees:
- Isolated IndexedDB Databases: Databases are scoped per tenant (
${dbName}:${userId}). User A and User B never share tables, cursors, or cached records. - Tenant-Guarded Queue Replay: Active synchronization loops filter queued operations by
userId, preventing previous users' pending mutations from replaying to the backend under a newly logged-in user's authentication headers. - Dedicated Web Locks & Cross-Tab Channels: Multi-tab concurrency locks and broadcast notifications are partitioned (
${dbName}:${userId}), preventing cross-tenant locking deadlocks or broadcast leaks across browser tabs. - Dynamic Subscriber Hot-Reloading: Calling
client.switchUser(newUserId)notifies all reactiveclient.subscribe()listeners to refresh their state with the new tenant's records seamlessly. - Secure Logout & Purge:
client.logout({ purgeData: true })orclient.purge()wipes sensitive records from the client device upon sign-out.
Cache Eviction & Storage Management
To prevent IndexedDB storage bloat and browser quota errors over long periods, offline-sync-lite provides lightweight, automatic and manual cache eviction:
1. Automatic Eviction (cacheLimits)
Configure maximum record limits per resource. After each successful sync, excess records are automatically pruned in the background without blocking the UI or sync loop:
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
cacheLimits: {
tasks: 500, // Automatically keep only the newest 500 tasks
},
onEvent: (evt) => {
if (evt.type === "cache_pruned") {
console.log(`Auto-pruned ${evt.pruned} tasks (${evt.kept} kept)`);
}
},
});2. Built-in Safeguards & Edge Cases
- Smart Eviction Sorting: Records are sorted by domain timestamps (
updatedAt/createdAt) first, falling back to local write time (_cachedAt). Older records from the server are evicted before recently updated records. - Unsynced Record Protection: Records with pending operations in the ops queue are never evicted. If the oldest record has an unsynced edit, it is safely preserved and the next oldest evictable record is removed.
- No Re-fetch Loops: Because delta synchronization uses
since={timestamp}, pruned records will not be redundantly re-fetched from the server unless they are modified server-side or a full sync is triggered.
Conflict Resolution, Clock Skew Mitigation & Field-Level Merging
Records have updatedAt (ISO string) and serverVersion (monotonic counter).
1. Granular Field-Level LWW (No Data Loss on Concurrent Edits)
In traditional document-level Last-Write-Wins (LWW), if User A edits task.status while User B concurrently edits task.title, the later timestamp completely wipes out the earlier user's changes.
offline-sync-lite provides built-in Field-Level Granular Resolution with automatic per-field timestamp tracking (_fieldTimes):
import { createSyncClient, createFieldMergeResolver } from "offline-sync-lite";
// Simple field-level merge (preserves non-overlapping property edits & latest values)
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
conflictResolver: "field-level", // or 'field'
});2. Custom Field Merge Strategies & Recursive Deep Merging
Configure customized property merge rules (such as array unions or numerical aggregations) and recursive nested object merging:
import { createFieldMergeResolver } from "offline-sync-lite";
const resolver = createFieldMergeResolver({
deep: true, // Recursively merge nested plain objects (e.g. metadata: { ... })
strategies: {
tags: "union", // Combine array items without duplicates
viewCount: "max", // Keep highest number
points: "sum", // Accumulate numerical edits
changelog: "concat", // Append strings/arrays
notes: (localVal, serverVal, key) => `${localVal}\n---\n${serverVal}`, // Custom merger
},
});
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
conflictResolver: resolver,
});3. Built-in Field Merge Strategies
| Strategy | Behavior | Supported Types |
| :------------------ | :-------------------------------------------------------------- | :-------------- |
| 'lww' (default) | Winner determined by field-level timestamp _fieldTimes[k] | Any |
| 'union' | Combines elements, deduplicating primitives and objects by id | Array |
| 'concat' | Concatenates arrays or strings | Array, String |
| 'max' | Selects highest numerical value (Math.max) | Number |
| 'min' | Selects lowest numerical value (Math.min) | Number |
| 'sum' | Sums both values (localVal + serverVal) | Number |
| function | Custom merge callback (localVal, serverVal, key, ctx) => any | Any |
4. Clock Skew Mitigation & Monotonicity Guarantees
- Passive NTP-lite Calibration: Whenever the client interacts with the server, it measures round-trip latency (RTT) and updates an Exponential Weighted Moving Average (EWMA) server clock offset from the standard HTTP
Dateheader orserverTimepayload. - Monotonic Causality Guarantees: When modifying existing records offline, the generated timestamp is guaranteed to be strictly greater than the record's prior timestamp (
Math.max(Date.now() + offset, priorTimestamp + 1)). This prevents devices with clocks set in the past from generating stale timestamps that would be silently overwritten by older server records. - Future Skew Protection: Skew offset adjustments normalize physical timestamps across devices, preventing forward-skewed clients from dominating all future conflict resolutions.
- Causal & Version Tiebreakers: When timestamps are identical, deterministic tie-breaking prefers the higher
serverVersionfollowed by server authority.
Events during sync:
onEvent: (evt) => {
if (evt.type === "conflict") {
console.log("Conflict for", evt.id, "→ applied", evt.merged);
}
};Schema Versioning, Migrations & Drift Protection
As applications evolve, data models change (fields get renamed, nested, or added with defaults). If an offline client loads data structured in an older schema, UI components can crash or attempt to replay invalid mutation payloads to upgraded backend endpoints.
offline-sync-lite provides Zero-Dependency Schema Versioning & Migrations:
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
schemaVersion: 3, // Target application domain schema version
migrations: {
// Migrate from v1 to v2: rename title to name, uppercase status
2: (data, record) => ({
...data,
name: data.title || data.name,
state: (data.status || "pending").toUpperCase(),
priority: data.priority || "NORMAL",
}),
// Migrate from v2 to v3: add tags array
3: (data, record) => ({
...data,
tags: data.tags || ["default"],
}),
},
onSchemaDrift: "purge", // 'notify' (default) | 'purge' | (driftInfo) => void
});Key Schema Features:
- At-Rest Record Migrations: When initialized, the client checks stored
schema:versionin IndexedDB. If out of date, it sequentially applies step migrations (v1 -> v2 -> v3) across all cached records. - Encrypted Storage Support: Transparently decrypts at-rest encrypted records, applies migration transforms, and re-encrypts records with Web Crypto AES-GCM.
- Queue Migration: Automatically migrates pending, un-synced offline operations in the
opsqueue (createandupdatepayloads) so they replay cleanly against upgraded backend endpoints without 4xx schema errors. - Server Drift Detection: Detects when backend API version outpaces local client (
x-schema-versionorschemaVersionin responses), emittingschema_driftevents and optionally purging obsolete cache partitions (onSchemaDrift: 'purge'). - Manual Migration: Programmatically run migrations on demand via
await client.migrate(targetVersion).
Auto-Sync & Mutation Control
Synchronization triggers automatically on:
- Local Mutations: When online, calling
create(),update(), orremove()immediately triggers a background sync cycle (enabled by default viasyncOnMutation: true). - Interval: Every
syncIntervalMsmilliseconds viastartAutoSync(). - Online Event: When the browser reconnects to the network (browser
onlineevent). - Manual: Programmatic trigger via
await client.syncNow().
Operations are immediately queued locally in IndexedDB regardless of network availability.
Manual / Scheduled Sync (syncOnMutation: false)
If your app prefers batching mutations, using an explicit "Sync Now" button, or testing multi-tab concurrent edits without immediate background sync, disable syncOnMutation:
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
syncOnMutation: false, // Prevents automatic sync on local create, update, or delete
syncIntervalMs: 30000, // Syncs periodically every 30 seconds instead
});
// Mutations persist locally and queue up without triggering network requests
await client.create({ id: "task-1", data: { title: "Draft" } });
await client.update("task-1", { title: "Draft v2" });
// Sync explicitly when ready
await client.syncNow();Poison Pill Protection & Dead-Letter Queue (DLQ)
When an operation fails with a non-retryable client error (e.g. 400 Bad Request, 422 Unprocessable Entity, or 403 Forbidden), standard sync loops can get trapped replaying the same invalid payload forever.
offline-sync-lite includes built-in Poison Pill Protection:
- Failure Threshold: When a non-retryable 4xx error occurs, the op's
permanentFailCountincrements. - Automatic Quarantine: Once
permanentFailCount >= maxPermanentFailures(default:5), the operation is markedstatus: 'dead'and quarantined in the Dead-Letter Queue. - No Backend Spam: Dead-lettered operations are automatically skipped during subsequent sync cycles, allowing valid operations queued behind them to process cleanly.
op_dead_letterEvent: An event is emitted with error details so your UI can alert the user (e.g., "Task could not be saved due to invalid data").- Full Administrative Control: Inspect with
client.getDeadLetterOps(), re-queue withclient.retryDeadLetterOp(opKey), or purge withclient.discardDeadLetterOps().
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
maxPermanentFailures: 5, // Quarantine after 5 permanent failures (set 0 to disable)
onEvent: (evt) => {
if (evt.type === "op_dead_letter") {
alert(`Operation failed permanently: ${evt.error}`);
}
},
});Multi-Tab Concurrency & Synchronization
When users open an application across multiple browser tabs, each tab typically initializes its own sync loop and online listeners. Without coordination, tabs can concurrently push the same queued operations, triggering duplicate requests, race conditions in IndexedDB, and wasted network bandwidth.
offline-sync-lite provides zero-dependency multi-tab synchronization:
1. Mutual Exclusion via Web Locks API (navigator.locks)
- When
syncNow()or auto-sync is triggered, the client attempts to acquire an exclusive non-blocking Web Lock (offline-sync-lite:sync:{dbName}). - If another tab is already running a sync cycle, the current tab immediately skips execution and returns
{ ok: true, skipped: true, pushedOps: 0, failedOps: 0, appliedUpdates: 0 }. - A
sync_skippedevent is emitted (reason: 'lock_held_by_other_tab') so you can track lock contention in telemetry. - If the syncing tab crashes or closes abruptly, the browser automatically releases the lock without deadlocks.
2. Cross-Tab Notifications via BroadcastChannel
- When the active tab finishes a sync cycle, it broadcasts a
sync_donemessage to all other open tabs on the channel (offline-sync-lite:channel:{dbName}). - Follower tabs automatically receive this event and trigger their
subscribe()listeners to refresh their local state from IndexedDB without performing duplicate network requests.
3. Configuration & Graceful Fallback
enableTabCoordination(default:true): Set tofalseto disable Web Locks and BroadcastChannel.- Graceful Degradation: In environments without Web Locks or BroadcastChannel support (e.g. Node.js SSR, older browsers), sync calls automatically proceed directly with zero runtime errors.
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
enableTabCoordination: true, // Enabled by default
onEvent: (evt) => {
if (evt.type === "sync_skipped") {
console.log("Sync skipped because another tab is syncing");
}
if (evt.type === "sync_done" && evt.crossTab) {
console.log("Data was synced by another tab!");
}
},
});At-Rest Storage Encryption & XSS Protection
By default, browser IndexedDB stores offline records and queued mutations in cleartext. If an application is compromised by an XSS attack or if unauthorized individuals inspect browser storage files on disk, sensitive data can be exposed.
offline-sync-lite includes zero-dependency at-rest envelope encryption powered by the native, hardware-accelerated Web Crypto API (AES-GCM-256 + PBKDF2 with SHA-256):
1. Field-Level Envelope Encryption
- Encrypted on disk:
record.dataandop.payloadare encrypted before writing to IndexedDB. - Fast IndexedDB Queries: Indexable envelope metadata (
id,updatedAt,_cachedAt,status, queuekey) remains plaintext so cursor scans, prefix queries (list()), cache eviction, and queue coalescing run at maximum speed without decrypting unneeded records. - Wire Safety: Outgoing network requests (
syncNow) send standard clean JSON payloads to your backend API over HTTPS; no server-side decryption changes are required.
2. Encryption Modes & Configuration
A. Passphrase / PIN Mode (PBKDF2 → AES-GCM 256)
Automatically derives a 256-bit AES-GCM key with 100,000 PBKDF2 iterations. A unique 16-byte cryptographic salt is generated and securely saved in the database meta store on first use, or provided explicitly:
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
encryption: {
passphrase: "user-provided-secret-or-pin",
iterations: 100000, // Optional (default: 100,000)
salt: "optional-custom-salt", // Optional (auto-generated & stored if omitted)
},
});B. Native CryptoKey Mode (WebAuthn / KMS)
Provide an existing Web Crypto CryptoKey instance:
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
encryption: {
key: myCryptoKey, // Native CryptoKey object with AES-GCM
},
});C. Custom Cipher Functions
Plug in custom encryption / decryption pipelines:
const client = createSyncClient({
apiUrl: "/api",
resourceName: "tasks",
encryption: {
encrypt: async (plainData) => {
/* return ciphertext */
},
decrypt: async (cipherData) => {
/* return plainData */
},
},
});3. Error Handling & Integrity Protection
AES-GCM includes built-in cryptographic authentication tags. If ciphertext is tampered with or an invalid passphrase is provided upon opening the database, decryption immediately throws:
Decryption failed: invalid key or corrupted ciphertext.
Metrics
Passively collected throughout lifecycle:
| Metric | Meaning |
| ---------------------- | ---------------------------------------- |
| syncSuccessRate | Percentage of syncs that completed (0–1) |
| avgSyncLatencyMs | Average of last 50 successful syncs |
| retryCount | Total operation retries |
| queuedOpsCount | Active operations pending sync |
| deadLetteredOpsCount | Quarantined operations in DLQ |
| conflictsDetected | Total conflicts resolved |
Server API Contract
Implement these REST endpoints. Records: {id, data, updatedAt, serverVersion}.
POST /api/{resourceName}
Create. Response: full record with serverVersion.
POST /api/{resourceName}/batch (Optional, NEW!)
Batch operations. For optimal performance, implement this endpoint to handle multiple operations in one request:
// Request
{ operations: [{ type: 'create', id: '1', payload: {...} }, ...] }
// Response
{ results: [{ success: true, record: {...} }, ...] }If not implemented, the SDK automatically falls back to individual operations.
PATCH /api/{resourceName}/{id}
Update. The request body contains { patch, updatedAt }:
patch: Key-value object containing only the modified fields.updatedAt: The baseline timestamp representing the state of the record before the client's local mutation was applied (Optimistic Concurrency Control).- When offline updates coalesce (
update + update), the initial baseline timestamp is preserved in the queue payload.
Backend OCC Conflict Handling:
- If
currentServerRecord.updatedAt !== requestBody.updatedAt, returnHTTP 409 Conflictwith the latest server record:{ "error": "Conflict", "remoteRecord": { "id": "1", "data": { ... }, "updatedAt": "...", "serverVersion": 2 } } - The SDK intercepts the 409, invokes your
conflictResolver(e.g. field-level merge or LWW), updates the local record and queue payload with the merged result, and retries the sync automatically.
DELETE /api/{resourceName}/{id}
Delete. Response: {ok: true}.
GET /api/{resourceName}?since={ISO8601}
List optionally since timestamp. Response: {records: [...], serverTime: ISO8601}.
How It Works
- Local ops: Immediately persist to IndexedDB and queue
- Coalesce: Merge multiple ops (create→update→single create)
- Push: Replay active queued ops with exponential backoff retry
- Poison Pill Guard: Quarantine permanent 4xx failures to Dead-Letter Queue when threshold is reached
- Handle conflicts: 409 → resolve → retry
- Pull: Fetch remote changes since last sync
- Merge: Apply newer remote records; resolve conflicts
- Notify: Emit events (
sync_done,op_dead_letter, etc.); update subscribers
Limitations
- Single resource per client: One
resourceNameper instance (create multiple clients for multiple resources) - Browser-only: Requires IndexedDB, fetch, navigator.onLine
- Last-write-wins default: Not application-semantic; use custom resolver for domain logic
- No transactions: Individual ops; no multi-doc ACID
- No auth in SDK: Implement in server middleware or
onEventhandler
Performance
- Bundle: ~18 KB minified, zero deps
- IndexedDB: Efficient prefix ranges for listing
- Sync: Typical 100–500 ms on good networks
- Coalescing: Reduces payload 50–80% for rapid updates
- Batch sync: 10× faster when server implements batch endpoint (10 ops in 1 request vs 10 requests)
Browser Support
Requires IndexedDB, fetch, ES2020. Tested on Chrome 90+, Firefox 88+, Safari 14+, Edge 90+.
License
MIT
