Appearance
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:
stringnumber(IEEE 754 floating-point — ~16 significant digits)booleannull- 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:
| Prefix | Purpose |
|---|---|
$$system | Internal 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) — throwsMutatorRaisedAnError - 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 Code | Name | Meaning |
|---|---|---|
4000 | KICKED | Duplicate clientId or admin action |
4001 | AUTH_FAILED | Invalid or expired authentication |
4002 | LIBRARY_VERSION_MISMATCH | Client/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:
- Clears all local data
- Fires the
onSchemaVersionMismatchcallback - 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:
- Server retries — The server retries the entire mutation batch up to 3 times, re-reading fresh state on each attempt
- 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
onFailedMutationcallbacks — 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.