Skip to content

Troubleshooting

Enable debug logging first

typescript
import { createConsoleLogger } from '@statesync/core';

const sync = createRevisionSync({
  ...options,
  logger: createConsoleLogger({ debug: true }),
});

This will show: subscribed, invalidation received, snapshot applied, revision skips.

Non-canonical revision

Error: Non-canonical snapshot revision: "01"

Revision must be a canonical decimal u64 string:

  • "0" — OK // [!code highlight]
  • "123" — OK // [!code highlight]
  • "01" — NOT OK (leading zero) // [!code error]
  • "abc" — NOT OK (not a number) // [!code error]
  • "" — NOT OK (empty string) // [!code error]

Fix: make sure the backend returns revision without leading zeros. Revision is the string representation of an unsigned 64-bit integer.

Topic mismatch

Symptom: invalidation events arrive, but the snapshot is not updated.

Cause: the topic in the invalidation event does not match the topic passed to createRevisionSync().

Fix: ensure backend and frontend use the same topic string. Topic comparison is strict (===).

Events arrive but state doesn't update

Symptoms: Console shows "invalidation received" but UI doesn't change, or getSnapshot is never called.

Checklist:

CheckHow to verify
Revision not increasingBackend must increment revision on EACH change
Stale revisionIf local revision >= event revision, skip is expected (not a bug)
Provider returns cached dataEnsure getSnapshot() fetches fresh data, not HTTP cache
Applier silently failsAdd console.log inside your applier.apply() function
Field in omitKeysCheck if the field you expect is excluded by omitKeys option

Debug snippet:

typescript
const sync = createRevisionSync({
  topic: 'my-topic',
  subscriber,
  provider: {
    async getSnapshot() {
      console.log('getSnapshot called'); 
      const data = await fetchData();
      console.log('returning', data); 
      return data;
    }
  },
  applier: {
    apply(snapshot) {
      console.log('apply called with', snapshot); 
      // your apply logic
    }
  },
  logger: createConsoleLogger({ debug: true }),
});

Multiple windows / race conditions

Symptom: multiple windows compete for updates; data “jumps”.

The engine provides:

  • Coalescing: multiple invalidations are collapsed into a single refresh
  • Revision monotonicity: snapshots are applied only if their revision is strictly greater than the current local revision

If the issue persists, verify that your snapshot provider returns fresh data (not cached).

start() after stop()

Error: [state-sync] start() called after stop()

The handle is single-use: after stop() you cannot call start() again. This protects against subscription leaks.

Fix: create a new handle via createRevisionSync().

Interpreting error phases

The phase field in SyncErrorContext helps you quickly identify the source of the problem:

getSnapshot

Cause: the provider failed to return a snapshot (network, timeout, backend down). Action: check backend availability. If you use Tauri invoke, ensure the Rust command is registered and returns data.

apply

Cause: the applier threw while processing the snapshot (deserialization error, invalid data, Pinia store rejection). Action: validate the snapshot data shape. Ensure the applier handles all expected forms of data.

protocol

Cause: contract violation — non-canonical revision, empty topic, or payload does not match the expected shape. Action: ensure backend generates a canonical revision (decimal u64 without leading zeros). Verify invalidation payloads.

subscribe

Cause: failed to subscribe to events (transport unavailable, Tauri listener error). Action: ensure transport is configured correctly and the event name matches.

start

Cause: failed during handle.start() setup. Action: check that subscriber and provider are properly configured.

invalidation

Cause: error processing an invalidation event. Action: verify event payload shape matches expected { topic, revision }.

refresh

Cause: fallback — an error inside refresh that isn't classified as getSnapshot/apply/protocol. Action: check logs for the full stack trace.

throttle

Cause: error in the throttle/coalescing layer. Action: check throttling configuration values.

onError throws

If the onError callback throws, the engine catches and logs it. The engine keeps running — a user callback cannot bring down the sync loop.

Persistence: localStorage quota exceeded

Error: localStorage quota exceeded for key "..."

Cause: localStorage has a ~5MB limit. Your state is too large.

Fix: Switch to IndexedDB backend:

typescript
import { createIndexedDBBackend } from '@statesync/persistence';

const storage = createIndexedDBBackend({
  dbName: 'my-app',
  storeName: 'state-cache',
  recordKey: 'my-data',
});

Or enable compression to reduce size:

typescript
import {
  createPersistenceApplier,
  createLZCompressionAdapter,
} from '@statesync/persistence';

const applier = createPersistenceApplier({
  storage,
  applier: innerApplier,
  compression: createLZCompressionAdapter(),
});

Persistence: cached data not loading

Symptom: loadPersistedSnapshot returns null even though data was saved.

Checklist:

CheckHow to verify
TTL expiredCheck if ttlMs is set and data is older than the TTL
Schema version mismatchPass migration option to handle version upgrades
Hash mismatchIf verifyHash: true, data may have been modified externally
Invalid revisionCached revision must be canonical (no leading zeros)

Zustand actions in omitKeys

Symptom: toState or snapshot apply throws because action functions are not plain objects.

Cause: Zustand stores include action functions in getState(). If your snapshot data matches the full state shape, the applier may try to write functions.

Fix: Use pickKeys to whitelist only data fields, or omitKeys to exclude actions:

typescript
const applier = createZustandSnapshotApplier(useMyStore, {
  mode: 'patch',
  pickKeys: ['name', 'count', 'items'],
});

See also

Released under the MIT License.