Skip to content

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.

AreaLimit or ruleValueOptions and notes
Store dataLive-data quota1,073,741,824 UTF-8 bytes (1 GiB) per store by defaultCounts 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 dataTotal live rowsNo count limitRows across all tables and users share the live-data byte quota.
WritesKey length1,786 UTF-8 bytesMeasures 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.
KeysSort key65,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.
WritesSerialized value1,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.
WritesRows per mutationNo count limitClient 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.
WritesClient-side mutation rate breaker200 mutations in any fixed 2-second windowEnforced 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.
WritespendingMutationDedupeKey256 UTF-8 bytesClient-enforced; shorten the key or omit it.
WritesMutation atomicityAll or nothingIf 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.
KeysReserved 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.
ValuesSupported typesJSON onlySupported: string, number, boolean, null, arrays, and plain objects. Serialize Date, BigInt, Map, Set, ArrayBuffer, class instances, and undefined before writing.
TransportClient-to-server message5,242,880 UTF-8 bytes (5 MiB) for the entire frameBreach 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.
TransportPush envelope budget4,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).
TransportConcurrent pushes6 per clientBounds parallel in-flight WebSocket pushes; additional pushes queue client-side in order and drain as responses arrive.
TransportServer-to-client message33,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.
TransportData sharing one sortKeyBounded by the store quota, not by one transport frameA 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.
PullingDefault local pull budget100,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.
PullingFirst-render budget25,000 estimated bytes (25 KB) by defaultSet 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.
PullingAlways-loaded rowsNo separate row cap; bounded by the total store quotasortKey: "" is read as one complete logical group. Put growing data behind non-empty sort keys so clients can page and evict it.
PullingAlways-loaded bytesExempt from pull budgets; bounded by the total store quotaAlways-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-storeExternal mutation targets per commit200 unique (storeTypeId, instanceId) pairsMultiple mutations to one target count once; a breach rejects the mutation.
Cross-storeDirect external mutation depth1A mutator reached by mutateExternal cannot call mutateExternal again. Indirect action hops share the 10-hop action-depth limit.
Cross-storeExternal read items1,000 per readResults are truncated. Page or narrow the read.
Cross-storeExternal read payload1,048,576 bytes (1 MiB) per readResults are truncated. Page or narrow the read.
Cross-storeExternal read timeout5 secondsThe read fails on timeout. Keep the target query bounded.
Cross-storeUnconfirmed optimistic external5 minutesThe 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-storeWatched external stores16 per sessionFurther watch requests are rejected. Unwatch targets that are no longer needed.
Cross-storePending dispatches1,000 waiting per target, plus the active RPCOverflow fails immediately. Reduce source fan-out or target recovery time.
Server executionGuest execution deadline30 seconds of wall-clock time per guest execution sliceMeasured 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 executionstructuredCloneAvailable in mutators, actions and module scopeCopies 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 executionAction retry delays30,000 milliseconds cumulative per action invocationctx.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 executionTile instance memory134,217,728 bytes (128 MiB)Includes QuickJS memory, queued action inputs, pending pokes, and file-upload buffers.
Server executionQuickJS guest heap50,331,648 bytes (48 MiB) per mutation/schema context; 8,388,608 bytes (8 MiB) per action contextIncludes 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 executionShared QuickJS memory115,343,360 bytes (110 MiB) of shared WASM engine memory per store instanceAll 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 executionEvent-loop ticks50,000 per callCounts 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 executionEnqueued-action chain10 hopsThe next hop is rejected. Intermediate commits are not rolled back.
Server executionConcurrent QuickJS actions8 active per store instanceAdditional top-level actions queue. A nested action is rejected immediately when all slots are occupied to avoid deadlock.
Server executionActive plus queued actions64 total (8 active and 56 queued)Further attempts are refused before execution; connected clients keep waiting and retry with backoff.
Server executionAction queue wait30 secondsThe attempt leaves the server queue without executing; connected clients retry with backoff. This is not a total client wait limit.
Server executionOptimistic-lock commit retry3 retries after the first attempt on retry-enabled pathsApp 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 repliesPending reply attempts256 per agent instance, shared across all chats using that instanceCounts 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 queueingWaiting mutation entries1,000Overflow rejects new queue entries. This is a backpressure bound rather than an app data-model allowance.
IdentifiersstoreTypeId and instance ID128 UTF-8 bytes for a storeTypeId, 256 UTF-8 bytes for an instance IDEnforced 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.
StorageRows per tableNo count limitNo separate per-table cap. Enforce an app-level retention policy and use sort keys for lazy loading.
StorageTables per storeUnboundedPrefer a stable, bounded schema; the live-data byte quota still applies across their rows.
StorageServer-side rows per transactionNot countedServer-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.
StoragePending mutations while offlineUnboundedLong offline sessions can accumulate indefinitely; keep mutations small and avoid high-frequency writes while disconnected.
StorageTombstone growthSelf-managing, not cappedThe platform bounds tombstone accumulation and cleans up incrementally; deleted rows do not count against the live-data quota.
StorageConcurrent clients per instanceUnbounded by synced-storeActual capacity is constrained by the hosting process and workload.
StorageSubscriptions per clientUnboundedApp code should release subscriptions that are no longer needed.
StorageSearch index sizeUnboundedApply an app-level indexing and retention policy.
MutatorsDeterminismRequiredMutators 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.
MutatorsExternal callsNot allowedUse 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.
ActionsClient executionServer-onlyActions have network latency and no optimistic result. ctx.enqueueAction() is a no-op during the client pass.
ConnectionsDuplicate clientIdOne active connectionThe newer connection kicks the older one with code 4000. Use one stable, unique client ID per tab/session.
ConnectionsTerminal close codes4000 kicked; 4001 auth failed; 4002 library mismatch; 4003 store not foundThese do not reconnect automatically. Reload or correct the underlying identity/auth/version issue.
ConnectionsReconnectable close codes1000 and 1006The client reconnects with backoff. Code 1009 means the outbound client frame was too large; shrink the pending mutation or it can repeat after reconnect.
SyncFast real-time multiplayerWorks, with visible delayEvery 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.
ConflictsMerge behaviorLast writer wins per row keyThere is no field-level merge, CRDT, or custom conflict callback. Use finer-grained rows when independent fields must merge.
Client cacheEviction thresholdMore than 1.2 times the local pull budgetThe 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.