npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

syncforge

v0.9.2

Published

A framework-agnostic offline-first engine for queuing, persisting, and synchronizing mutations across unreliable networks.

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

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 syncforge
npm install syncforge
yarn add syncforge

React

syncforge-react is a separate package — core syncforge has zero React dependencies.

pnpm add syncforge-react syncforge
npm install syncforge-react syncforge

Peer 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: persistoperation:optimisticoperation:queued

mutate() without handlers: persistoperation:queued

Successful flush (per operation): operation:syncingoperation:succeeded

Retryable transport failure: operation:syncingoperation:queued

Terminal transport failure: operation:syncingoperation:rollbackoperation: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-storage
import 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/preferences
import { 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

  1. Records a change — call mutate(type, payload). type is your label; SyncForge does not interpret it.
  2. Stores it safely — operations can be persisted through a storage adapter so they survive reloads and reconnects.
  3. Sends it when you are ready — call flush(). Your transport receives each SyncOperation and decides how to call your API.
  4. 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, and createdAt are 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 / rollback on mutate() are session-scoped only — they merge with registry handlers but are not persisted. After a page reload, only optimisticHandlers[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

  1. Compact regularly — call compact() after successful flush(), on app startup, or on a schedule. Large completed backlogs slow hydration on every cold start.
  2. Inspect counts onlyawait sync.inspect() returns counts by default. Avoid inspect({ operations: [...] }) on large queues unless you need specific operation rows.
  3. In React — prefer useSyncSnapshot({ operations: ["pending", "failed"] }) over full snapshots when the UI only needs active work.
  4. 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 queue

Counts 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 backfilled
  • totalQueued increments on mutate() only — not on retry(id) re-queues.
  • compact(), remove(), and clear() 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

  1. healthy — no threshold breached; breachedSignals is empty
  2. degraded — at least one soft threshold breached
  3. unhealthy — 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

  • failureRate is session-only — same semantics as getMetrics(), not a rolling window
  • storageBytesEstimate is 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 completedCount inflates queueSize and storageBytesEstimate — run compact() 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. Use inspect().isSyncing separately 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-react for hooks and provider
  • Not a networking stack — you implement TransportAdapter for 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:changed event
  • [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