syncforge
v0.9.2
Published
A framework-agnostic offline-first engine for queuing, persisting, and synchronizing mutations across unreliable networks.
Maintainers
Readme
SyncForge
Don't lose user actions when the network drops.
SyncForge aims to be the simplest way to guarantee mutation delivery in offline-capable applications without adopting a local database or replacing your existing API.
SyncForge is a small TypeScript library that saves changes locally when your app is offline or on a bad connection, then sends them to your server when you're back online. It works with any frontend framework and any backend — you bring your own API.
Using React? See syncforge-react — official provider and hooks (useSyncEngine, useSyncFlush, useSyncStatus) on top of the same engine.
Maintained by Frank K. Abrokwa (@codewithcobby)
Table of contents
- Project status
- Installation
- React integration
- Quick start
- API reference
- The problem
- What SyncForge does
- Architecture
- How it works
- Lifecycle events
- Why use SyncForge?
- Why not X?
- What SyncForge is not
- Roadmap
- License
Project status
SyncForge is currently in active development.
| Status | Details |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Implemented | Mutation queue, transport adapter, memory & IndexedDB storage, auto sync on reconnect, retries, lifecycle events, optimistic updates, syncforge-react |
| Tested | Core engine, IndexedDB persistence, auto sync, retry strategies, optimistic handlers, syncforge-react hooks |
| Planned before v1.0 | Example ecosystem expansion |
Installation
pnpm add syncforgenpm install syncforgeyarn add syncforgeReact
syncforge-react is a separate package — core syncforge has zero React dependencies.
pnpm add syncforge-react syncforgenpm install syncforge-react syncforgePeer dependencies: react, react-dom, syncforge. Full setup, transport patterns, and hook reference: syncforge-react README.
React integration
Official React bindings live in syncforge-react. Pass a pre-created engine to the provider; hooks subscribe to lifecycle events so you do not wire useEffect + engine.on() yourself.
import { useMemo } from "react"
import { createIndexedDbStorage, createSyncEngine } from "syncforge"
import { SyncForgeProvider, useSyncEngine, useSyncFlush, useSyncStatus } from "syncforge-react"
const engine = createSyncEngine({
storage: createIndexedDbStorage(),
transport: myTransport,
autoSync: true,
})
function App() {
return (
<SyncForgeProvider engine={engine}>
<OrderForm />
<SyncIndicator />
</SyncForgeProvider>
)
}
function SyncIndicator() {
const status = useSyncStatus()
return (
<span>
{status.pendingCount} pending{status.isSyncing ? " (syncing…)" : ""}
</span>
)
}
function OrderForm() {
const engine = useSyncEngine()
const flush = useSyncFlush()
// engine.mutate(...) · flush() for manual sync
}| Export | Description |
| ------------------------------------------------------------------- | ----------------------------------------------------------------- |
| SyncForgeProvider | Share one SyncEngine via context (does not mutate the engine) |
| useSyncEngine() | Raw SyncEngine reference — mutate(), on(), engine.flush() |
| useSyncFlush() | Optional tracked flush() for “Sync now” UI |
| useSyncMutate() | Ergonomic mutate() with optimisticData and inline overrides |
| useSyncStatus() | { pendingCount, isSyncing, lastError } |
Docs: packages/react/README.md · Example: examples/react-offline-orders · Try online: StackBlitz demo
Quick start
The first argument to mutate() is an operation label your app defines (e.g. "createOrder"). SyncForge stores it and passes the full operation to your transport on flush(). Your transport decides which API to call and how to map operation.type and operation.payload.
API reference
createSyncEngine(options?)
| Option | Type | Default | Description |
| -------------------- | ----------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------- |
| transport | TransportAdapter | — | Sends each operation to your API. Required for flush() to work. |
| storage | StorageAdapter | createMemoryStorage() | Persists the queue across reloads. Use createIndexedDbStorage() in browsers. |
| retry | RetryStrategy | immediateRetryStrategy | Delay between retry attempts after a failed send(). |
| maxRetries | number | 3 | Max transport attempts per operation before status becomes failed. |
| autoSync | boolean | true | Browser-only. Calls flush() on window "online". Ignored in Node/SSR. Set false to disable. |
| context | TContext | — | User-owned state (store, query client, etc.) passed to optimistic handlers. |
| optimisticHandlers | Record<string, OptimisticHandler> | — | Registry of apply / rollback handlers keyed by operation.type. Survives reload. |
TransportAdapter — object with send(operation: SyncOperation): Promise<void>. Throw on failure to trigger a retry; resolve on success.
SyncEngine methods
| Method | Arguments | Returns | Description |
| --------------------------------- | -------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| mutate(type, payload, options?) | type: string (your label), payload: any, options?: MutateOptions | Promise<SyncOperation> | Enqueues a mutation. Runs optimistic apply after persist when handlers exist. Emits operation:optimistic then operation:queued. |
| flush() | — | Promise<{ successful, failed }> | Sends all pending operations via transport. Requires transport. |
| getPending() | — | Promise<SyncOperation[]> | Operations with status pending. |
| getFailed() | — | Promise<SyncOperation[]> | Operations with status failed (terminal transport failure). |
| retry(id) | id: string | Promise<boolean> | Re-queue a failed operation (pending, retries = 0, clears lastError). Does not re-run apply. |
| retryAllFailed() | — | Promise<number> | Re-queue all failed operations. Returns count of operations actually re-queued. Does not call flush(). |
| compact() | — | Promise<number> | Remove all completed operations from storage. Preserves pending, syncing, and failed. Returns count removed. |
| inspect(options?) | options?: InspectOptions | Promise<InspectSnapshot> | Read-only queue snapshot — status counts; optional filtered operation list via operations. |
| getMetrics() | — | MetricsSnapshot | Synchronous session counters — queued, succeeded, failed, retried since engine creation. Not persisted across reload. |
| getHealth(options?) | options?: HealthOptions | Promise<HealthSnapshot> | Read-only operational health snapshot — queue signals, session failure rate, storage estimate, status, and breachedSignals. |
| remove(id) | id: string | Promise<boolean> | Removes one operation by id. true if found. |
| clear() | — | Promise<void> | Removes all operations from the queue. |
| destroy() | — | Promise<void> | Removes the online listener (if any), then clears the queue. |
| on(type, listener) | type: event name, listener: (event) => void | void | Subscribe to lifecycle events (see below). |
| off(type, listener) | Same as on | void | Unsubscribe a listener. |
SyncOperation fields: id, type, payload, status (pending | syncing | completed | failed), retries, createdAt, optimisticData? (persisted metadata for rollback), lastError? (set on terminal failure; cleared by retry(id)).
MutateOptions: optimisticData? (persisted on the operation), optimisticUpdate? and rollback? (session-scoped inline overrides that merge with registry handlers — see Optimistic updates).
Storage adapters
Full guide: docs/storage-adapters.md — decision matrix, per-adapter setup, migration, and limits.
| Environment | Recommended adapter |
| ------------ | --------------------------------- |
| Browser | createIndexedDbStorage() |
| React Native | createAsyncStorageAdapter() |
| Capacitor | createCapacitorStorageAdapter() |
| Tests / Node | createMemoryStorage() |
| Adapter | Factory | Environment | Durability | Size limit | Package |
| ------------ | --------------------------------- | ---------------- | ---------- | ---------- | --------------------------------------------------------- |
| Memory | createMemoryStorage() | Tests, Node, SSR | None | RAM | syncforge |
| IndexedDB | createIndexedDbStorage() | Browser | Full | Large | syncforge |
| LocalStorage | createLocalStorageStorage() | Browser | Full | ~5MB | syncforge |
| AsyncStorage | createAsyncStorageAdapter() | React Native | Full | Platform | syncforge + @react-native-async-storage/async-storage |
| Capacitor | createCapacitorStorageAdapter() | Hybrid mobile | Full | Platform | syncforge + @capacitor/preferences |
| Factory | Options | Environment |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| createMemoryStorage() | — | Node, tests, anywhere (in-memory; lost on reload) |
| createIndexedDbStorage(options?) | dbName? (default "syncforge"), storeName? (default "operations") | Browser only (IndexedDB) |
| createLocalStorageStorage(options?) | key? (default "syncforge-queue"), prefix? (default "") | Browser only (localStorage) |
| createAsyncStorageAdapter(options) | asyncStorage (required), key?, prefix? — inject @react-native-async-storage/async-storage | React Native (injected AsyncStorage) |
| createCapacitorStorageAdapter(options) | preferences (required), key?, prefix? — inject @capacitor/preferences | Capacitor (injected Preferences) |
Examples: react-offline-orders (IndexedDB) · localstorage · react-native-asyncstorage · capacitor-preferences · examples index
Migrating adapters: manual load → reviveOperations() → save — see migration guide.
Lifecycle events (SyncEventTypes)
| Event | When |
| ---------------------- | --------------------------------------------------------- |
| operation:optimistic | After optimistic apply runs (handlers only) |
| operation:queued | After mutate() persists the operation |
| operation:syncing | Before transport.send() during flush() |
| operation:succeeded | Transport resolved successfully |
| operation:rollback | After rollback handler on terminal failure |
| operation:failed | Max retries exceeded (after rollback when handlers exist) |
Queue lifecycle (separate from operation lifecycle):
| Event | When |
| --------------- | ------------------------------------------------------------------- |
| queue:changed | After any successful queue mutation (membership, status, or counts) |
Operation events carry { type, operation, timestamp, error? }. queue:changed carries { type, timestamp } only — no operation field. Read-only APIs (inspect(), hydrate()) do not emit.
retryAllFailed() emits one queue:changed per successfully retried operation (same as calling retry(id) in a loop).
Event ordering (public contract)
mutate() with handlers: persist → operation:optimistic → operation:queued
mutate() without handlers: persist → operation:queued
Successful flush (per operation): operation:syncing → operation:succeeded
Retryable transport failure: operation:syncing → operation:queued
Terminal transport failure: operation:syncing → operation:rollback → operation:failed
retry(id) on failed operation: status reset → persist → operation:queued (no re-apply)
retryAllFailed(): per operation, same as retry(id); sequential when multiple ops
Retry strategies
| Factory | Options | Description |
| ----------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------- |
| immediateRetryStrategy | — | No delay between retries (default). |
| exponentialBackoffRetryStrategy() | baseDelayMs?, maxDelayMs?, factor?, jitter? | min(base × factor^attempt, maxDelayMs); optional 50%–100% jitter. |
| linearBackoffRetryStrategy() | baseDelayMs?, maxDelayMs? | min(base × attempt, maxDelayMs); no jitter. |
getDelay(attempt) receives the post-failure retry count (operation.retries after increment). The first failed send passes attempt: 1.
With baseDelayMs: 1_000 and defaults:
| Strategy | getDelay(1) | getDelay(2) | getDelay(3) |
| ----------- | ------------- | ------------- | ------------- |
| Linear | 1_000 ms | 2_000 ms | 3_000 ms |
| Exponential | 2_000 ms | 4_000 ms | 8_000 ms |
After a failed send(), the engine waits getDelay(retries) before flush() finishes. The operation stays pending; call flush() again (or rely on auto sync on reconnect) for the next transport attempt.
import { createSyncEngine, exponentialBackoffRetryStrategy, linearBackoffRetryStrategy } from "syncforge"
const sync = createSyncEngine({
transport: myTransport,
retry: exponentialBackoffRetryStrategy({
baseDelayMs: 1_000,
maxDelayMs: 30_000,
factor: 2,
jitter: true,
}),
maxRetries: 5,
})
const syncLinear = createSyncEngine({
transport: myTransport,
retry: linearBackoffRetryStrategy({
baseDelayMs: 1_000,
maxDelayMs: 30_000,
}),
maxRetries: 5,
})When jitter: true on exponential backoff, the actual delay is randomized between 50% and 100% of the calculated exponential delay — so repeated failures will not wait for an exact millisecond value. The exact jitter algorithm is not part of the public API and may evolve.
syncforge-react
See React integration and the syncforge-react README.
IndexedDB adapter
Production browser default. See IndexedDB guide for options, limits, and migration.
import { createIndexedDbStorage, createSyncEngine } from "syncforge"
const transport = {
async send(operation) {
switch (operation.type) {
case "createOrder":
await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(operation.payload),
})
break
default:
throw new Error(`Unknown operation type: ${operation.type}`)
}
},
}
const sync = createSyncEngine({
transport,
storage: createIndexedDbStorage({ dbName: "my-app", storeName: "sync-queue" }),
})
await sync.mutate("createOrder", { customerId: "123", total: 100 })
// Optional: flush immediately when you want sync now (auto sync also runs on reconnect)
const result = await sync.flush()
console.log(result) // { successful: 1, failed: 0 }createIndexedDbStorage() is browser-only — it requires IndexedDB (not available in Node.js or SSR). Use createMemoryStorage() for tests, scripts, and server environments. Set a unique dbName per app on the same origin to avoid queue collisions.
Runnable demo: examples/react-offline-orders.
For lightweight browser apps that do not need IndexedDB capacity, see LocalStorage adapter below (createLocalStorageStorage()).
Auto sync on reconnect is enabled by default in browsers (autoSync defaults to true). When the network comes back, SyncForge calls flush() for you — no window.addEventListener("online", ...) boilerplate. Set autoSync: false for full manual control. Node.js and SSR ignore this option.
LocalStorage adapter
For small widgets, prototypes, and simple apps that do not need IndexedDB. See LocalStorage guide and example snippet.
import { createLocalStorageStorage, createSyncEngine } from "syncforge"
const sync = createSyncEngine({
storage: createLocalStorageStorage({ prefix: "my-app:", key: "syncforge-queue" }),
transport: myTransport,
})Prefix and key: resolved storage key is `${prefix ?? ""}${key ?? "syncforge-queue"}`. Include any separator in prefix (e.g. "my-app:") — SyncForge concatenates as-is and does not insert colons or dashes.
When to use: prototypes, embedded widgets, small queues, quick browser testing without fake-indexeddb.
When not to use: large queues, payloads larger than a few KB each, or anything approaching large-queue guidance. Prefer createIndexedDbStorage() for production PWAs and offline-first apps.
Limits: ~5MB per origin; underlying localStorage API is synchronous (the adapter wraps it in async methods only). The full queue is rewritten as one JSON document on every mutation — same model as IndexedDB.
Isolation: use a unique prefix (with separator) or key per app/widget on shared origins.
Quota errors: when storage is full, saveOperations() throws StorageError:
LocalStorage quota exceeded (~5MB limit). Consider compact(), reducing queue size, or switching to createIndexedDbStorage().
React Native (AsyncStorage)
Install @react-native-async-storage/async-storage in your app (not a SyncForge core dependency — inject the instance). See AsyncStorage guide.
npm install @react-native-async-storage/async-storageimport AsyncStorage from "@react-native-async-storage/async-storage"
import { createAsyncStorageAdapter, createSyncEngine } from "syncforge"
const sync = createSyncEngine({
storage: createAsyncStorageAdapter({ asyncStorage: AsyncStorage, key: "syncforge-queue" }),
transport: myTransport,
autoSync: false,
})Pass the full AsyncStorage object — SyncForge uses only getItem, setItem, and optionally removeItem. Other methods (multiGet, mergeItem, clear, etc.) are ignored.
Prefix and key: same rules as LocalStorage — `${prefix ?? ""}${key ?? "syncforge-queue"}`.
Reconnect / flush: React Native has no window online event. Set autoSync: false and call sync.flush() when connectivity returns (e.g. via @react-native-community/netinfo). Optionally flush on AppState active when the app returns to foreground. See examples/react-native-asyncstorage.
Limits: same single-document rewrite model as other adapters; prefer smaller queues and run compact() periodically. Use getHealth() to monitor growth. IndexedDB is not available on RN.
Empty queue: when injected AsyncStorage supports removeItem, SyncForge clears the storage key instead of persisting "[]".
Capacitor (Preferences)
Install @capacitor/preferences in your app (not a SyncForge core dependency — inject the instance). See Capacitor guide.
npm install @capacitor/preferencesimport { Preferences } from "@capacitor/preferences"
import { createCapacitorStorageAdapter, createSyncEngine } from "syncforge"
const sync = createSyncEngine({
storage: createCapacitorStorageAdapter({ preferences: Preferences, key: "syncforge-queue" }),
transport: myTransport,
autoSync: false,
})Pass the full Preferences object — SyncForge uses only get, set, and optionally remove. Other methods (configure, clear, keys, migrate, etc.) are ignored.
When to use what:
| Backend | Use when |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Preferences (this adapter) | Default for Capacitor iOS/Android — native-backed key/value, simple setup |
| IndexedDB / LocalStorage | Capacitor web builds only — same as browser adapters when running in a desktop browser |
| SQLite (@capacitor-community/sqlite) | Not built into SyncForge — consider for large relational data or custom query needs; implement a custom StorageAdapter or watch for a future issue |
Prefix and key: same rules as LocalStorage — `${prefix ?? ""}${key ?? "syncforge-queue"}`.
Reconnect / flush: on native Capacitor, set autoSync: false and call sync.flush() when connectivity returns (e.g. via @capacitor/network). Optionally flush on @capacitor/app appStateChange when the app returns to foreground. See examples/capacitor-preferences.
Limits: same single-document rewrite model as other adapters; prefer smaller queues and run compact() periodically. Use getHealth() to monitor growth.
Empty queue: when injected Preferences supports remove, SyncForge clears the storage key instead of persisting "[]".
Memory storage
In-memory queue for tests, Node, and SSR — no persistence across reloads. See Memory guide.
import { createMemoryStorage, createSyncEngine } from "syncforge"
const sync = createSyncEngine({
transport: myTransport,
storage: createMemoryStorage(),
autoSync: false, // Node has no window — auto sync is a no-op here anyway
})Next.js example
In a client component or server action handler, queue a mutation and flush when the network is available:
"use client"
import { createMemoryStorage, createSyncEngine } from "syncforge"
const sync = createSyncEngine({
transport: {
async send(operation) {
switch (operation.type) {
case "createOrder":
await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(operation.payload),
})
break
default:
throw new Error(`Unknown operation type: ${operation.type}`)
}
},
},
storage: createMemoryStorage(),
})
export async function createOrder(total: number) {
await sync.mutate("createOrder", { total })
await sync.flush()
}Transport adapter examples
Single endpoint — post the full operation; your backend reads operation.type:
class RestTransport {
async send(operation) {
await fetch("/api/mutations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(operation),
})
}
}Routed endpoints — map operation.type to the right API:
class RoutedTransport {
async send(operation) {
const { type, payload } = operation
switch (type) {
case "createOrder":
await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
break
case "updateProfile":
await fetch("/api/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
break
default:
throw new Error(`Unknown operation type: ${type}`)
}
}
}The problem
Imagine a user filling out a form on a train, in a basement, or on spotty Wi‑Fi. They tap Save. The request fails. Their work is gone — or they have to try again manually.
Most apps either:
- Show an error and hope the user retries, or
- Bolt on complex state management that is hard to test and maintain
SyncForge gives you a dedicated layer for "save now, sync later" without turning your app into a database or a React-specific toolkit.
What SyncForge does
- Records a change — call
mutate(type, payload).typeis your label; SyncForge does not interpret it. - Stores it safely — operations can be persisted through a storage adapter so they survive reloads and reconnects.
- Sends it when you are ready — call
flush(). Your transport receives eachSyncOperationand decides how to call your API. - Tells you what happened — lifecycle events fire when operations are queued, syncing, succeeded, or failed.
You stay in control of what gets sent and how. SyncForge handles the queue, persistence, and retry flow.
Architecture
SyncForge sits between your application and two pluggable adapters. The core owns the operation lifecycle; adapters own delivery and persistence.
flowchart LR
subgraph Application
App[Your App]
end
subgraph SyncForgeCore["SyncForge Core"]
Queue[Operation Queue]
Lifecycle[Lifecycle + Retries]
Events[Event Emitter]
end
subgraph Adapters
direction TB
Transport[Transport Adapter]
Storage[Storage Adapter]
end
Backend[(Backend API)]
Persistence[(Persistence Layer)]
App -->|mutate / flush / on| SyncForgeCore
Queue --- Lifecycle
Lifecycle --- Events
SyncForgeCore <-->|load / save operations| Storage
Storage <--> Persistence
SyncForgeCore -->|send operation| Transport
Transport -->|REST · GraphQL · tRPC · custom| Backend| Layer | Responsibility |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Application | Calls mutate(), flush(), and subscribes to events — or use syncforge-react hooks in React apps |
| SyncForge Core | Queues operations, tracks status, retries, and emits lifecycle events |
| Transport Adapter | Maps operation.type + operation.payload to your backend |
| Storage Adapter | Persists the operation queue across reloads |
| Backend | Your existing API — SyncForge does not replace it |
| Persistence | Memory, IndexedDB, LocalStorage, AsyncStorage, and Capacitor Preferences adapters |
How it works
End-to-end flow
sequenceDiagram
autonumber
participant App as Application
participant SF as SyncForge Core
participant Store as Storage Adapter
participant Transport as Transport Adapter
participant API as Backend API
App->>SF: mutate("createOrder", payload)
SF->>Store: saveOperations([...])
SF-->>App: emit operation:queued
App->>SF: flush()
SF->>Store: load pending operations
loop each pending operation
SF->>SF: status = syncing
SF-->>App: emit operation:syncing
SF->>Transport: send(operation)
Transport->>API: your API call
alt success
API-->>Transport: 2xx
Transport-->>SF: resolved
SF->>SF: status = completed
SF-->>App: emit operation:succeeded
else failure
API-->>Transport: error
Transport-->>SF: rejected
SF->>SF: retry or status = failed
SF-->>App: emit operation:failed
end
SF->>Store: saveOperations([...])
end
SF-->>App: { successful, failed }Operation lifecycle
stateDiagram-v2
[*] --> pending: mutate()
pending --> syncing: flush()
syncing --> completed: transport succeeds
syncing --> pending: transport fails<br/>(retries remaining)
syncing --> failed: max retries exceeded
completed --> [*]
failed --> [*]API
See API reference in Quick start for full method and option details.
| Method | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| mutate(type, payload) | Enqueue a change (always safe to call) |
| flush() | Send pending operations via transport; returns { successful, failed } |
| getPending() | List operations still waiting to sync |
| on("operation:…") | React to queue and sync status in your UI — or use useSyncStatus() in React |
Behavior guarantees
- Concurrent
flush()calls share one in-flight sync — operations are never sent twice (including auto sync on reconnect). - Mutations made during
flush()are queued for the next flush, not the current one. - After reload, operation
status,retries, andcreatedAtare restored correctly.
Lifecycle events
Use these to drive UI: spinners, toasts, "synced" badges, or error states.
import { SyncEventTypes } from "syncforge"
sync.on(SyncEventTypes.Queued, ({ operation }) => {
console.log("queued", operation.id)
})
sync.on(SyncEventTypes.Syncing, ({ operation }) => {
console.log("syncing", operation.id)
})
sync.on(SyncEventTypes.Succeeded, ({ operation }) => {
console.log("synced", operation.id)
})
sync.on(SyncEventTypes.Failed, ({ operation }) => {
console.log("failed", operation.id)
})
sync.on(SyncEventTypes.Optimistic, ({ operation }) => {
console.log("optimistic", operation.id)
})
sync.on(SyncEventTypes.Rollback, ({ operation, error }) => {
console.log("rollback", operation.id, error)
})Optimistic updates
SyncForge does not own your application state. You pass optional context at engine creation (Zustand, Redux, React state, etc.) and register optimisticHandlers keyed by operation.type. Handlers run apply after the operation is persisted and rollback only on terminal transport failure (when maxRetries is exhausted).
Registry = reload-safe recovery. Inline
optimisticUpdate/rollbackonmutate()are session-scoped only — they merge with registry handlers but are not persisted. After a page reload, onlyoptimisticHandlers[type]can run rollback.
Handler merge: apply = inline.optimisticUpdate ?? registry.apply · rollback = inline.rollback ?? registry.rollback. Inline overrides one side at a time; the other side falls back to the registry.
optimisticData on mutate() is persisted on the operation (JSON-serializable metadata for rollback, e.g. a temp id). Callbacks are never persisted.
On reload: pending operations are hydrated from storage but apply is not re-run. Reconcile UI from your own state + getPending(). If a hydrated op later fails, registry rollback uses optimisticData.
Failed operations: use getFailed() and retry(id) — or retryAllFailed() for bulk recovery. Both reset to pending, clear lastError, and do not re-run apply.
const sync = createSyncEngine({
transport,
storage: createIndexedDbStorage(),
context: { orderStore },
optimisticHandlers: {
createOrder: {
apply(operation, { orderStore }) {
orderStore.add(operation.payload)
},
rollback(operation, _error, { orderStore }) {
orderStore.remove(operation.optimisticData?.tempId ?? operation.payload.id)
},
},
},
})
await sync.mutate("createOrder", order, {
optimisticData: { tempId: order.id },
})
// After terminal failure — single operation:
const failed = await sync.getFailed()
if (failed.length > 0) {
await sync.retry(failed[0].id)
await sync.flush()
}
// Bulk recovery:
const retried = await sync.retryAllFailed()
if (retried > 0) {
await sync.flush()
}In React, subscribe to operation:optimistic and operation:rollback via useSyncEngine().on() — useSyncStatus() stays sync-queue focused (no forced re-renders for optimistic UI). See syncforge-react optimistic events.
Queue compaction
After a successful flush(), operations stay in storage with status === "completed" until removed. Long-lived PWAs can accumulate thousands of completed rows, slowing hydration and growing IndexedDB.
Call compact() to remove completed operations while preserving pending, syncing, and failed:
await sync.flush()
const removed = await sync.compact()
if (removed > 0) {
console.log(`Cleaned ${removed} completed operations`)
}When to call:
- After successful
flush()in batch workflows - On app startup (after the engine hydrates)
- Periodically in long-lived PWAs
compact() hydrates first, then waits for any active flush() to finish before removing completed operations. If persistence fails, the call rejects and storage remains unchanged; reload restores the persisted queue.
Production guidance — large queues
SyncForge stores the entire queue as one JSON document in IndexedDB (or your StorageAdapter). Every mutate(), flush status transition, and compact() rewrites the full in-memory array to storage. Cost grows with queue size and payload size.
Operational playbook
- Compact regularly — call
compact()after successfulflush(), on app startup, or on a schedule. Large completed backlogs slow hydration on every cold start. - Inspect counts only —
await sync.inspect()returns counts by default. Avoidinspect({ operations: [...] })on large queues unless you need specific operation rows. - In React — prefer
useSyncSnapshot({ operations: ["pending", "failed"] })over full snapshots when the UI only needs active work. - Keep pending batches reasonable — flush re-persists the full queue on each status transition (~2 writes per successful operation). A small pending batch on a large completed backlog still triggers full-array writes.
Realistic risk profile
| Queue shape | Primary risk |
| ---------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Large completed backlog (e.g. 50k+ without compact()) | Slow hydrate() / startup |
| Small pending on large total (e.g. 500 pending, 99.5k completed) | Flush write amplification (saveOperations called ~2× per pending op) |
| Large payloads (10 KB+ per operation) | Memory pressure during JSON parse/stringify |
Benchmarks
See docs/benchmarks/large-queue-methodology.md for how to run local benchmarks and reference measurements.
Reference measurements are not guarantees. Results depend on machine, Node/browser version, and payload size. Re-run with
pnpm benchmark:queue.
Not solved in v0.8
- Sharded / per-operation IndexedDB layout
- Streaming or Worker-based flush
- Automatic compaction policies
If benchmarks show unacceptable flush write amplification in your app, track a separate optimization issue (e.g. flush persist coalescing) — Issue #11 validates behavior; it does not require engine changes when results are acceptable.
Queue inspection
For diagnostics and support tooling, inspect() returns a read-only snapshot of queue state — no persistence, no side effects. Point-in-time counts for every status; does not wait for an active flush() to finish.
const snapshot = await sync.inspect()
// { pending, failed, completed, syncing, total, isSyncing }
const supportView = await sync.inspect({ operations: ["failed"] })
// supportView.operations — shallow copies; mutating them does not affect the queueCounts only by default — safe when thousands of completed operations exist. Pass operations: ["pending", "failed"] (or other statuses) only when you need operation rows. Pair with compact() so completed counts stay manageable.
Queue metrics
getMetrics() returns cumulative session statistics — what has happened since createSyncEngine(). It is synchronous and does not hydrate storage.
const metrics = sync.getMetrics()
// {
// totalQueued: 42,
// totalSucceeded: 38,
// totalFailed: 2,
// totalRetried: 15,
// averageRetries: 0.39,
// lastSuccessfulFlushAt: Date | null,
// }| API | Answers |
| -------------- | --------------------------------------------------- |
| inspect() | What is in the queue right now? (point-in-time) |
| getMetrics() | What has happened this session? (cumulative) |
Session semantics
- Counters start at zero on each
createSyncEngine()call. - Hydrated operations do not contribute to metrics. Storage may already hold hundreds of completed operations after reload — metrics still read zero until new events occur this session.
// Storage already contains 500 completed operations
const sync = createSyncEngine({ storage })
await sync.inspect() // completed: 500
sync.getMetrics() // totalSucceeded: 0 — historical queue is not backfilledtotalQueuedincrements onmutate()only — not onretry(id)re-queues.compact(),remove(), andclear()do not change metrics.destroy()clears the queue but preserves session metrics on that engine instance.- Metrics are not persisted across page reload or new engine instances.
Retry counting
totalRetried counts each failed transport send attempt (maxRetries > 0). Example: fail, fail, then succeed → totalSucceeded = 1, totalRetried = 2, averageRetries = 2.
averageRetries is derived at read time: totalSucceeded > 0 ? totalRetried / totalSucceeded : 0.
Boundary cases:
| maxRetries | After one failed flush | totalFailed | totalRetried |
| ------------ | ---------------------- | ------------- | -------------- |
| 0 | terminal on first fail | 1 | 0 |
| 1 | terminal on first fail | 1 | 1 |
lastSuccessfulFlushAt is set when a flush() batch completes with at least one success — useful for “when did the queue last make progress?”
Queue health
getHealth() returns an operational verdict — is the queue healthy right now, and which signals breached? It composes hydrated queue state (inspect() counts), session metrics (getMetrics() failure rate), and a cached UTF-8 JSON size estimate of the in-memory queue.
const health = await sync.getHealth()
// {
// queueSize: 42,
// pendingCount: 3,
// failedCount: 1,
// completedCount: 38,
// oldestPendingAgeMs: 120_000,
// storageBytesEstimate: 65_536,
// failureRate: 0.02,
// status: "healthy" | "degraded" | "unhealthy",
// breachedSignals: ["pendingCount", "oldestPendingAgeMs"],
// }| API | Answers |
| -------------- | ------------------------------------------------------------------- |
| inspect() | What is in the queue right now? (point-in-time) |
| getMetrics() | What has happened this session? (cumulative) |
| getHealth() | Is the queue healthy right now, and which signals breached? |
Status semantics
healthy— no threshold breached;breachedSignalsis emptydegraded— at least one soft threshold breachedunhealthy— at least one hard threshold breached
Worst-signal-wins: any unhealthy breach → unhealthy; else any degraded → degraded.
Default thresholds
Overridable per call via getHealth({ thresholds }). See DEFAULT_HEALTH_THRESHOLDS export.
| Signal | Degraded | Unhealthy |
| ----------------------- | -------- | --------- |
| queueSize (total) | 10,000 | 50,000 |
| pendingCount | 100 | 500 |
| oldestPendingAgeMs | 1 hour | 24 hours |
| failureRate (session) | 5% | 20% |
| storageBytesEstimate | 5 MB | 20 MB |
failureRate only affects status when totalSucceeded + totalFailed >= 10 (avoids 1 failed / 1 total → instant unhealthy).
Support dashboard example
const health = await sync.getHealth()
if (health.status !== "healthy") {
console.warn("Queue health:", health.status, health.breachedSignals)
if (health.completedCount > health.pendingCount) {
console.info("Consider compact() — completed backlog inflates queueSize")
}
}Important notes
failureRateis session-only — same semantics asgetMetrics(), not a rolling windowstorageBytesEstimateis serialized JSON size of the in-memory queue, not IndexedDB on-disk overhead; cached and recomputed on queue mutations — safe for periodic polling, not per-frame- Large
completedCountinflatesqueueSizeandstorageBytesEstimate— runcompact()before interpreting health (see large-queue methodology) - Thresholds are defaults for status pages, not SLAs — override via
getHealth({ thresholds }) getHealth()is read-only — no persist, no events. Useinspect().isSyncingseparately if you need sync-in-progress state
Why use SyncForge?
| You get | Why it matters |
| --------------------------- | -------------------------------------------------------------------------------------------------- |
| Offline-first by design | User actions are captured even when the network is not available |
| Framework-agnostic | Use with React (syncforge-react), Vue, Svelte, or plain JavaScript |
| Pluggable transport | Your API, your auth, your format — SyncForge does not care |
| Persistent queue | Operations survive reloads (with a storage adapter) |
| Observable lifecycle | Hook into events for UI, logging, or devtools later |
| Small surface area | Not a database, not a state manager, not a networking framework |
Good fit: forms, carts, notes, field apps, or any flow where losing a mutation is worse than delaying it — including optimistic UI when you own the store.
Not a fit (yet): full local-first databases or conflict resolution / CRDTs.
Why not X?
Why not React Query?
React Query focuses on server-state caching and request lifecycles. SyncForge focuses on guaranteed mutation delivery across unreliable networks. They work well together: React Query for reads and cache management, SyncForge for offline mutation durability.
Why not PouchDB?
PouchDB is a local database with replication features. SyncForge is a focused mutation queue and sync engine. If you only need reliable mutation delivery with your existing API, SyncForge keeps the architecture simpler.
What SyncForge is not
SyncForge core stays intentionally small:
- Not a database — it queues mutations, it does not replace your data layer
- Not a React library in core — use
syncforge-reactfor hooks and provider - Not a networking stack — you implement
TransportAdapterfor your API
That keeps the library easy to reason about and easy to adopt one piece at a time.
Roadmap
- [x] Mutation queue
- [x] Memory storage adapter
- [x] IndexedDB storage adapter
- [x] LocalStorage storage adapter
- [x] React Native AsyncStorage adapter
- [x] Capacitor Preferences storage adapter
- [x] Transport adapter
- [x] Lifecycle events
- [x] Retry strategy interface
- [x] Automatic sync when back online
- [x] Exponential and linear retry strategies
- [x] Optimistic updates
- [x] React integration —
syncforge-react - [x]
retryAllFailed()bulk helper - [x]
compact()queue cleanup - [x]
inspect()queue snapshot - [x]
queue:changedevent - [x]
useSyncSnapshot()React hook - [x]
usePendingOperations()/useFailedOperations()convenience hooks - [x] Large-queue stress validation and production guidance (v0.8)
- [x]
getMetrics()queue statistics - [x]
getHealth()operational health checks
License
MIT © Frank K. Abrokwa
