Skip to content

Limitations

This page documents the constraints and limits of Synced-Store. Understanding these helps you design your app within supported boundaries and avoid runtime errors.

Use Case Fit

Synced-Store is designed for collaborative apps with eventual consistency:

  • Chat apps and turn-based games work well
  • FPS games and other latency-sensitive real-time simulations do not work well
  • Data syncs relatively fast (under 1 second), but there is no hard latency guarantee

Size Limits

Keys

The maximum key length is 256 bytes, measured as the byte length of the table name and item key combined. Exceeding this throws KeyTooLargeError.

typescript
import { KeyTooLargeError } from "@synced-store/shared";

try {
  await ctx.table("todos").set(veryLongKey, { title: "..." });
} catch (error) {
  if (error instanceof KeyTooLargeError) {
    console.error("Key too large:", error.message);
  }
}

Values

The maximum value size is 1 MB (1,048,576 bytes), measured as the byte length of JSON.stringify(value). Exceeding this throws ValueTooLargeError.

typescript
import { ValueTooLargeError } from "@synced-store/shared";

try {
  await ctx.table("documents").set("doc-1", largeObject);
} catch (error) {
  if (error instanceof ValueTooLargeError) {
    console.error("Value too large:", error.message);
  }
}

TIP

Both maxKeyLength and maxValueLength are configurable in client options, but server-side enforcement uses the defaults. Increasing them on the client alone won't bypass server limits.

Pull Responses

Each pull response is capped at 1 MB by default (DEFAULT_PULL_MAX_BYTES). If the store has more data, the client loads it incrementally across multiple pulls. You can adjust this via initialPullMaxBytes in your client config, or at runtime via client.pullMaxBytes for "load more" patterns.

Upload Size

The total upload size for an app (HTML, CSS, JS, static assets) is limited to 50 MB.

Data Types

Values must be valid JSON. Supported types:

  • string
  • number (IEEE 754 floating-point — ~16 significant digits)
  • boolean
  • null
  • Arrays and nested objects of the above

Not supported: undefined, Date, BigInt, Map, Set, ArrayBuffer, or class instances. Serialize these to JSON-compatible types before storing.

Key and Naming Constraints

Item Keys

Item keys must be non-empty. Passing an empty string throws an error.

typescript
// Throws: "Invalid patch: itemKey must be non-empty"
await ctx.table("todos").set("", { title: "..." });

Sort Key Namespaces

Sort key namespaces have two constraints:

  • Must be non-empty
  • Cannot contain : — the colon is reserved as an internal delimiter

Reserved Prefixes

Table names starting with $$ are reserved for internal use:

PrefixPurpose
$$systemInternal system metadata
$$pu/{userId}/Private user data
$$so/Server-only data

You cannot write to $$-prefixed tables from your mutators. Use ctx.privateOfUser() and ctx.serverOnly() to access private and server-only data through the supported API.

Mutator Constraints

Determinism

Mutators run on both client and server, so they must be deterministic given the same inputs and state:

  • No randomness — Use crypto.randomUUID() outside the mutator and pass the result as input
  • No Date.now() for unique values — The client and server will produce different timestamps. Pass timestamps as input if you need consistent values across both environments.
  • No external API calls — Use actions for server-only operations

See Mutators > Best Practices for patterns that avoid these issues.

State-Dependent Mutations

Mutations that toggle or invert current state (like !todo.done) can produce surprising results during rebase when other clients make concurrent changes. Always pass the intended value explicitly rather than computing it from current state. See Mutators > Use Explicit Values Instead of Toggles.

Access Restrictions

  • Mutators cannot access server-only data ($$so/ prefix) — throws MutatorRaisedAnError
  • Mutators running on the client cannot access other users' private data — throws MutatorRaisedAnError
  • Services (API keys, blob storage, etc.) are only available in actions, not mutators

Action Constraints

  • Actions run only on the server — there is no optimistic local update
  • The client waits for the server response, so actions have network latency
  • Actions enqueued via ctx.enqueueAction() from a mutator are no-op on the client and only execute on the server after the mutation commits

Connection Constraints

One Client Per Tab

Each browser tab should have its own unique clientId. If two clients connect with the same clientId, the first one is kicked with WebSocket close code 4000.

Terminal Disconnections

Some disconnections are permanent — the client will not attempt to reconnect:

Close CodeNameMeaning
4000KICKEDDuplicate clientId or admin action
4001AUTH_FAILEDInvalid or expired authentication
4002LIBRARY_VERSION_MISMATCHClient/server version incompatible — reload required

Non-terminal disconnections (codes 1000 and 1006) trigger automatic reconnection with exponential backoff.

Schema Version Mismatch

When the server's schema version doesn't match the client's, the client:

  1. Clears all local data
  2. Fires the onSchemaVersionMismatch callback
  3. Disposes itself

The app should handle this by reloading or re-initializing the client with the updated schema.

Client-Side Storage

Eviction

When cached data in IndexedDB grows beyond the pullMaxBytes budget (multiplied by a 1.2x threshold), the client automatically evicts the least-important data:

  • Later pull windows are evicted first (lowest priority)
  • Within a window, data is evicted from the far edge (furthest from the cursor)
  • Evicted data will be re-fetched from the server on next pull if needed

This means very large stores won't exhaust browser storage, but clients may not have a complete local copy of all data at all times.

Singleton Table Type Safety

Key constraints for singletonTable() are TypeScript compile-time checks only. There is no runtime enforcement preventing writes to invalid keys — the type system catches these errors during development, not at runtime.

Conflict Resolution

Synced-Store uses a last-writer-wins model at the key level. There is no built-in support for:

  • Field-level merging (e.g., two users editing different fields of the same object)
  • CRDTs or operational transforms
  • Custom conflict resolution callbacks

If two clients write to the same key concurrently, the server processes them in order and the last write wins. Design your data model with fine-grained keys to minimize conflicts — for example, store each field as a separate key rather than grouping many fields into one object.

Optimistic Lock Conflicts

The server uses optimistic locking to ensure mutations are applied against the expected state. When a mutation reads a key and then writes to it, the server asserts the key's version hasn't changed between read and write. If another client's mutation commits in between, the assertion fails with an optimistic_lock_conflict error.

The system handles this automatically:

  1. Server retries — The server retries the entire mutation batch up to 3 times, re-reading fresh state on each attempt
  2. Client retries — If all server retries fail, the client receives the failure and automatically re-pushes the mutation on the next push cycle

In most apps, this is invisible — retries succeed and the mutation eventually commits. However, under high write contention (many clients writing to the same key rapidly), you may observe:

  • Increased latency — mutations take longer to confirm as retries accumulate
  • onFailedMutation callbacks — if retries are exhausted, the failed mutation is reverted locally and subscribers are notified
  • Temporary optimistic state rollback — the client rolls back the failed mutation's local changes and rebases remaining pending mutations
typescript
client.onFailedMutation((info) => {
  console.error(
    `Mutation "${String(info.mutation.name)}" failed:`,
    info.error.message,
    `(${info.error.errorType})`,
  );
});

Design for low contention

Avoid patterns where many clients frequently write to the same key (e.g., a single "counter" key incremented by all users). Instead, use per-user keys and aggregate on read, or use actions for operations that must serialize writes.