Sync Lifecycle

valtio-sync saves local changes automatically, but the application starts the first remote sync. A successful local write does not by itself mean the change has reached the server.

What Happens After a Mutation

When application code mutates an account or collection proxy, valtio-sync:

  1. updates the reactive proxy immediately;
  2. marks the affected fields as dirty;
  3. batches the local persistence work for approximately 100 ms; and
  4. stores the change in the client's local persistence.

The client does not automatically POST the change after this batch. Call sync() to send all pending operations and pull remote changes:

sync.todos.records.todo_1.completed = true
await sync.sync()

Collection-level flush() and sync() methods delegate to the same client-wide operations, so they are conveniences rather than collection-only network requests.

flush() Compared with sync()

  • await sync.flush() waits for pending local writes to commit durably and recomputes the pending operation list. It rejects on persistence failure and does not contact the server.
  • await sync.sync() flushes local writes, POSTs pending operations, and applies the server response.

Use flush() in tests, diagnostics, and code that must know local persistence has caught up. Use sync() when changes should be remotely backed up or when the client should pull newer server state.

What is saved together

During a flush, the storage adapter saves account and record changes together: either all changes succeed or none do. sync() sends a mutation only after that local save succeeds.

After a server response, the client also saves these together:

  • Server acknowledgements.
  • Records received from the server.
  • The new server cursor, which tracks how far the client has synced.

What Is Automatic

  • Proxy mutation tracking and local persistence are automatic.
  • Failed network requests and non-auth HTTP failures are retried automatically while dirty work remains, using exponential backoff with jitter.
  • Validation, authorization, conflict, and other operation rejections are not retried unchanged.
  • There is no automatic initial sync, sync-after-every-mutation, polling, or server-push/realtime connection.

Automatic retries begin only after an application-triggered sync attempt fails. Merely creating dirty local state does not schedule a remote request.

Intercepting the Transport

sync.interceptTransport() wraps future protocol requests before the built-in HTTP transport. An interceptor can:

  • Pass through or modify a SyncRequest.
  • Replace its SyncResponse.
  • Return null to drop the attempt.

Scheduled retries use the currently installed interceptors too.

A dropped attempt counts as neither success nor failure. It does not:

  • Advance the server cursor.
  • Apply a response.
  • Clear dirty operations.
  • Schedule another retry.

Removing ops before sending a request also leaves those local operations pending. A later sync() may send them after the interceptor is removed.

For fixture data, use preventRemoteWrites with a temporary memory storage adapter. The interceptor blocks uploads while still allowing remote reads. The memory adapter keeps fixture edits separate from the saved local cache.

Restore the default adapter before removing the interceptor. A storage namespace does not change server authentication. See Testing for an example.

Choosing Sync Triggers

Choose when the app should send local changes and fetch newer server state. Common triggers are:

  • Startup after authentication.
  • Important save actions.
  • Reconnecting to the network.
  • Returning to a visible tab.
  • A periodic timer while the app is active.
await sync.hydrate()
await sync.sync()

const syncWhenOnline = () => void sync.sync()
window.addEventListener('online', syncWhenOnline)

const timer = window.setInterval(() => void sync.sync(), 30_000)

// During application teardown:
window.removeEventListener('online', syncWhenOnline)
window.clearInterval(timer)
sync.close()

Calling sync() while another sync is running returns without starting an overlapping request. Choose a polling interval appropriate for the application's traffic, battery, and freshness requirements.

Edits made during a sync

Local edits may continue while a request is running. An acknowledgement applies only to the state sent in that request. Newer local fields remain dirty for a later sync.

If the server returns a corrected record, the client applies it without overwriting those newer local edits.

Closing a tab before sync completes

Do not rely only on browser shutdown hooks to upload changes. Browsers may not let those requests finish.

Changes already saved locally remain pending if a tab closes before remote sync succeeds. They can be sent when the client opens again.

Other devices will see those changes only after the upload succeeds and they sync to fetch them.

Realtime Expectations

valtio-sync is designed for single-user save state and multi-device synchronization, not realtime collaboration. It does not keep devices continuously current or provide a server-driven change stream. Applications that need live collaboration or immediate cross-device updates need an additional realtime transport and conflict model.