Appearance
Synced-store limits
This table describes the synced-store behavior shipped on main. Sizes are UTF-8 bytes — the same unit as the wire and the live-data quota; multi-byte characters cost 2–4 bytes each — unless a row says otherwise.
| Area | Limit or rule | Value | Options and notes |
|---|---|---|---|
| Store data | Live-data quota | 1,073,741,824 UTF-8 bytes (1 GiB) per store by default | Counts every live public, private, server-only, system table row as the UTF-8 byte length of sortKey + storedTableName + itemKey + JSON.stringify(value). Non-ASCII text therefore costs more than its character count: 2 bytes for most accented Latin, 3 for CJK, 4 for emoji. Tombstones, metadata, and file/blob contents do not count. The exact boundary is allowed; a growth past it fails with TotalDatabaseSizeExceededError. An over-limit store may still shrink. The platform configures this quota; apps should prune or split growing collections. A store still awaiting the platform's exact-accounting backfill is not enforced. |
| Store data | Total live rows | No count limit | Rows across all tables and users share the live-data byte quota. |
| Writes | Key length | 1,786 UTF-8 bytes | Measures tableName + ":" + itemKey as UTF-8 bytes — the same unit the live-data quota meters, so an ASCII character costs 1 byte and most emoji cost 4. sortKey is excluded (it has its own row below). Enforced by client and server with KeyTooLargeError. maxKeyLength may impose a smaller client limit (also UTF-8 bytes) but cannot raise the server limit. |
| Keys | Sort key | 65,536 UTF-8 bytes (64 KiB) | Independent of the table/item key allowance. Enforced by client, trusted host, and server with KeyTooLargeError; the exact boundary is allowed. Exported as MAX_SORT_KEY_BYTES; importing it from the client SDK requires runtime version 38 (poeTile({ minSdkVersion: 38 }), also inferred by the Vite plugin). The complete key returned by getNextSortKey() includes the namespace, slash, and counter suffix. Scan keys, prefixes, cursors, and pull namespaces use the same byte allowance. Shorten keys or store large content in the value. |
| Writes | Serialized value | 1,048,576 UTF-8 bytes (1 MiB) | Measures the UTF-8 byte length of JSON.stringify(value) — an ASCII string can contain at most 1,048,574 characters (its quotes count), while multi-byte characters cost 2–4 bytes each. Enforced by client and server with ValueTooLargeError. maxValueLength may impose a smaller client limit (also UTF-8 bytes). Validation, the live-data quota, and the transport all measure UTF-8, so one validated value always fits a push frame — but a whole mutation rides one frame under a budget below the transport limit, and a single over-budget mutation is rejected client-side rather than split. Split or separately send writes when one mutation carries many large values. |
| Writes | Rows per mutation | No count limit | Client and server mutations remain subject to the live-data byte quota, key/value limits, transport limits, and execution budgets. Keep bulk work bounded to fit those limits. |
| Writes | Client-side mutation rate breaker | 200 mutations in any fixed 2-second window | Enforced in the trusted host, per mounted tile per tab. This is a breaker for runaway write loops (a mutation re-triggered by its own subscription), not a rate to design toward: apps that commit coalesced results instead of per-frame input (see architecture-scaling-and-performance.md) run far below it. If a client exceeds it, the platform unmounts the tile and replaces it with an error UI headed “This tile was stopped.” The UI explains that the app sent too many state mutations, shows a reference ID, and offers a Retry button. |
| Writes | pendingMutationDedupeKey | 256 UTF-8 bytes | Client-enforced; shorten the key or omit it. |
| Writes | Mutation atomicity | All or nothing | If a mutator throws or breaches a limit, none of its writes commit. Other mutations in the same push may still commit. Server state is authoritative, so use onFailedMutation and onBackgroundError for failures discovered after the optimistic pass. |
| Keys | Reserved characters | : and null (\0) are forbidden in tableName, itemKey, and sortKey; itemKey must be non-empty | $-prefixed tables are platform-owned and $$-prefixed tables are reserved internals. Choose another key encoding. |
| Values | Supported types | JSON only | Supported: string, number, boolean, null, arrays, and plain objects. Serialize Date, BigInt, Map, Set, ArrayBuffer, class instances, and undefined before writing. |
| Transport | Client-to-server message | 5,242,880 UTF-8 bytes (5 MiB) for the entire frame | Breach closes the WebSocket with code 1009. One push can contain multiple mutations plus envelope overhead, so split or separately send writes when the whole frame approaches the limit. |
| Transport | Push envelope budget | 4,194,304 UTF-8 bytes (4 MiB) | The frame limit minus the serialized-value cap. The client batches pending mutations into pushes up to this size and splits larger sets across pushes automatically, so every legal single-value mutation stays sendable with framing headroom. A single mutation whose serialized values exceed the budget is rejected client-side rather than split (see the Serialized value row). |
| Transport | Concurrent pushes | 6 per client | Bounds parallel in-flight WebSocket pushes; additional pushes queue client-side in order and drain as responses arrive. |
| Transport | Server-to-client message | 33,554,432 UTF-8 bytes (32 MiB) Cloudflare ceiling; synced-store targets pull responses below 8,388,608 UTF-8 bytes (8 MiB) | Large logical sort-key groups are split across numbered response messages, keeping ordinary pull frames comfortably below the deployed transport ceiling. |
| Transport | Data sharing one sortKey | Bounded by the store quota, not by one transport frame | A sort-key group remains atomic to application code. The server may stream it through multiple messages; the client buffers all fragments before applying, persisting, widening its loaded range, or notifying subscribers. A missing or malformed fragment aborts the version seal and retries the pull. Use multiple sort keys for independently pageable data, not to work around frame size. |
| Pulling | Default local pull budget | 100,000 estimated bytes (100 KB) | This is a target, not a hard cap; a whole sort-key group may exceed it. Set initialLocalBudgetBytes to change the initial target. There is no load-everything sentinel: 0 disables budgeted expansion and eviction — clients still receive always-loaded (sortKey: "") rows, each outward pull window's initial anchor group, and updates to ranges they already loaded, but nothing expands beyond that. To keep a whole store resident, set a budget comfortably above the store's size — the pull budget meters a separate client-cache estimate (a fast approximation using JavaScript string lengths — most characters count 1 and emoji count 2, so multi-byte text counts fewer than its live-data bytes — plus roughly 50 bytes of per-row overhead), so a store of many small rows needs a budget several times its live-data bytes — and make sure the schema's pull windows cover the rows. The client also adjusts the budget at runtime. |
| Pulling | First-render budget | 25,000 estimated bytes (25 KB) by default | Set schema firstRenderBytes to override the target for the first page across all pull windows. Metered by the same client-cache estimate as the local pull budget above — not live-data UTF-8 bytes, so a multi-byte-text-heavy first page can be 2–3× its stated budget in real bytes. A whole sort-key group can exceed it, and always-loaded rows do not consume it. |
| Pulling | Always-loaded rows | No separate row cap; bounded by the total store quota | sortKey: "" is read as one complete logical group. Put growing data behind non-empty sort keys so clients can page and evict it. |
| Pulling | Always-loaded bytes | Exempt from pull budgets; bounded by the total store quota | Always-loaded rows are streamed in bounded transport fragments when needed, but every client still buffers and loads the complete sortKey: "" group on every pull. Keep it small for client memory and bandwidth. |
| Cross-store | External mutation targets per commit | 200 unique (storeTypeId, instanceId) pairs | Multiple mutations to one target count once; a breach rejects the mutation. |
| Cross-store | Direct external mutation depth | 1 | A mutator reached by mutateExternal cannot call mutateExternal again. Indirect action hops share the 10-hop action-depth limit. |
| Cross-store | External read items | 1,000 per read | Results are truncated. Page or narrow the read. |
| Cross-store | External read payload | 1,048,576 bytes (1 MiB) per read | Results are truncated. Page or narrow the read. |
| Cross-store | External read timeout | 5 seconds | The read fails on timeout. Keep the target query bounded. |
| Cross-store | Unconfirmed optimistic external | 5 minutes | The client reverts an overlay that remains unconfirmed past the TTL. Dispatch is detached from the source push; retryable infrastructure failures may be retried, so receivers must be idempotent or convergent. Ordinary mutator failures and overload responses are not retried. |
| Cross-store | Watched external stores | 16 per session | Further watch requests are rejected. Unwatch targets that are no longer needed. |
| Cross-store | Pending dispatches | 1,000 waiting per target, plus the active RPC | Overflow fails immediately. Reduce source fan-out or target recovery time. |
| Server execution | Guest execution deadline | 30 seconds of wall-clock time per guest execution slice | Measured in wall-clock time while your code runs, per mutator, action, or hook call; trusted host operations refresh the deadline when they settle, so this is not a total wall-clock limit on host I/O. Because the budget is wall time on the instance's single thread, identical compute can take longer under concurrent load — leave headroom rather than designing near the deadline. Bound scans and external work. The deadline is checked against a clock that does not advance while your code computes without awaiting a host operation, so a slice that never yields is not stopped at 30 seconds: it runs until the store's CPU limit (300 seconds) resets the instance, failing every request in flight on that store. Never write an unbounded synchronous loop. |
| Server execution | structuredClone | Available in mutators, actions and module scope | Copies plain objects, arrays, Date, RegExp, Map, Set, ArrayBuffer, typed arrays and DataView, keeping shared references and cycles. Errors keep message and stack but lose cause and other properties. Functions, symbols, WeakMap, WeakSet, WeakRef, FinalizationRegistry, Promise, iterators and generators (sync or async), SharedArrayBuffer, detached or out-of-bounds buffers and views, and a non-empty transfer list throw an error named DataCloneError; on the server it is an Error, not a DOMException, so check error.name. A Proxy (for example reactive-state objects) throws in the browser but is copied as plain data on the server, so do not rely on either. |
| Server execution | Action retry delays | 30,000 milliseconds cumulative per action invocation | ctx.sleep(ms) accepts integer milliseconds from 0 through 30,000. Sequential and concurrent requests share the allowance; exceeding it rejects before creating a timer. It is a retry delay, not a timeout or cancellation mechanism for another host call. |
| Server execution | Tile instance memory | 134,217,728 bytes (128 MiB) | Includes QuickJS memory, queued action inputs, pending pokes, and file-upload buffers. |
| Server execution | QuickJS guest heap | 50,331,648 bytes (48 MiB) per mutation/schema context; 8,388,608 bytes (8 MiB) per action context | Includes loaded code and retained module state. Mutations called from an action share its limit of 8,388,608 bytes (8 MiB). Exceeding the cap fails the operation. Prefer bounded scans over materializing a growing table. These caps are not memory reservations. |
| Server execution | Shared QuickJS memory | 115,343,360 bytes (110 MiB) of shared WASM engine memory per store instance | All of the store's action and mutation contexts share this capacity, including retained runtimes and allocator overhead. An operation can exhaust the shared capacity before reaching its own guest limit. Host JavaScript and buffers consume additional memory outside this cap. |
| Server execution | Event-loop ticks | 50,000 per call | Counts each host operation drained, plus turns that only process queued promises. Exceeding the budget fails the call. Bound loops and recursive work; parallel host operations still count separately. |
| Server execution | Enqueued-action chain | 10 hops | The next hop is rejected. Intermediate commits are not rolled back. |
| Server execution | Concurrent QuickJS actions | 8 active per store instance | Additional top-level actions queue. A nested action is rejected immediately when all slots are occupied to avoid deadlock. |
| Server execution | Active plus queued actions | 64 total (8 active and 56 queued) | Further attempts are refused before execution; connected clients keep waiting and retry with backoff. |
| Server execution | Action queue wait | 30 seconds | The attempt leaves the server queue without executing; connected clients retry with backoff. This is not a total client wait limit. |
| Server execution | Optimistic-lock commit retry | 3 retries after the first attempt on retry-enabled paths | App pushes and mutations invoked from server actions are not retried server-side on a conflicted commit; clients re-push conflicted writes with backoff until they commit. Only platform maintenance paths use the bounded retries. |
| Agent replies | Pending reply attempts | 256 per agent instance, shared across all chats using that instance | Counts queued and in-flight reply attempts, including accepted work still awaiting delivery. A new attempt past the cap is rejected with Reply observer pending capacity exceeded; retry after earlier replies recover; retrying an already-accepted attempt is always admitted — reuse the original attempt identity. Back off new submissions while full and retry after earlier replies finish or recover. |
| Server queueing | Waiting mutation entries | 1,000 | Overflow rejects new queue entries. This is a backpressure bound rather than an app data-model allowance. |
| Identifiers | storeTypeId and instance ID | 128 UTF-8 bytes for a storeTypeId, 256 UTF-8 bytes for an instance ID | Enforced where identifiers enter the platform, not on every route: instance creation caps both at 256 bytes, and the tile RPC boundary caps storeTypeId at 128 bytes and instance IDs at 256 bytes for external-store pulls and background sync; external-store mounts accept storeTypeIds up to 1,000 bytes. Tile handles, which become storeTypeIds, are capped at 100 (ASCII only — a-z, 0-9, - — so bytes and characters agree). Internal registration paths accept more, but staying within the app-facing bounds above is what guarantees every path accepts the identifier. |
| Storage | Rows per table | No count limit | No separate per-table cap. Enforce an app-level retention policy and use sort keys for lazy loading. |
| Storage | Tables per store | Unbounded | Prefer a stable, bounded schema; the live-data byte quota still applies across their rows. |
| Storage | Server-side rows per transaction | Not counted | Server-only writes, hooks, platform system mutators and schema migrations are bounded by the live-data byte quota and the server execution limits. Keep them bounded yourself. |
| Storage | Pending mutations while offline | Unbounded | Long offline sessions can accumulate indefinitely; keep mutations small and avoid high-frequency writes while disconnected. |
| Storage | Tombstone growth | Self-managing, not capped | The platform bounds tombstone accumulation and cleans up incrementally; deleted rows do not count against the live-data quota. |
| Storage | Concurrent clients per instance | Unbounded by synced-store | Actual capacity is constrained by the hosting process and workload. |
| Storage | Subscriptions per client | Unbounded | App code should release subscriptions that are no longer needed. |
| Storage | Search index size | Unbounded | Apply an app-level indexing and retention policy. |
| Mutators | Determinism | Required | Mutators run optimistically and authoritatively. Pass timestamps, random IDs, and desired final values as inputs; do not generate them with Date.now(), Math.random(), or toggle expressions. See mutator rules. |
| Mutators | External calls | Not allowed | Use actions for platform calls (ctx.platform.call). Store code cannot make direct requests to arbitrary external hosts: the backend runtime's fetch throws, and the client iframe's Content Security Policy blocks third-party hosts. ctx.serverOnly() and another user's private data are unavailable on the client, so guard server-only access with ctx.isServer. |
| Actions | Client execution | Server-only | Actions have network latency and no optimistic result. ctx.enqueueAction() is a no-op during the client pass. |
| Connections | Duplicate clientId | One active connection | The newer connection kicks the older one with code 4000. Use one stable, unique client ID per tab/session. |
| Connections | Terminal close codes | 4000 kicked; 4001 auth failed; 4002 library mismatch; 4003 store not found | These do not reconnect automatically. Reload or correct the underlying identity/auth/version issue. |
| Connections | Reconnectable close codes | 1000 and 1006 | The client reconnects with backoff. Code 1009 means the outbound client frame was too large; shrink the pending mutation or it can repeat after reconnect. |
| Sync | Fast real-time multiplayer | Works, with visible delay | Every change travels through the instance's one server before other players see it, so shooters, racing, fighting, and other games that sync positions or hits between players many times a second feel laggy and jumpy, and worse for players far from that server. A player's own input feels instant; everyone else's arrives late. Prefer turn-based play, asynchronous competition (score chases, ghost runs, leaderboards), co-op where exact timing does not decide the outcome, or simulating locally and syncing only results. See live synced state. |
| Conflicts | Merge behavior | Last writer wins per row key | There is no field-level merge, CRDT, or custom conflict callback. Use finer-grained rows when independent fields must merge. |
| Client cache | Eviction threshold | More than 1.2 times the local pull budget | The client evicts least-important cached data and refetches it on a later pull. Raise initialLocalBudgetBytes, call the runtime load-more API, or improve pull windows when the working set should stay resident. |
Apps can use the live data limit hook to compact records or archive them to files in the same mutation when a growing store reaches 80% of its live data allowance.