# Runtime and limits (/docs/schema/collaborative-documents/runtime)

---
title: Runtime and limits
description: Collaboration adds two POST routes to the handler you already mount, and nothing else. No socket server, no sidecar, no second process.
kind: guide
package: questpie
---

Everything below is operational surface rather than schema. What runs and where,
who is allowed to open a session, and the ceiling the protocol enforces before it
rejects you.

## The two routes

`createFetchHandler()` already mounts them, so they live under the same base
path as the rest of your API, `/api` in the starters:

```text
POST /api/realtime/crdt/open
POST /api/realtime/crdt/exchange
```

`open` takes JSON and returns a session. `exchange` carries the binary protocol
that moves updates, pulls, receipts, awareness and heartbeats. Both work
wherever the fetch handler works, including TanStack Start, Hono, Next and
Elysia.

## What the realtime connection carries

The client reuses the one lazy SSE or Pusher connection it already holds. Over
it, collaboration sends nothing but a hint that something changed. No updates,
no field names, no cursors, no grants, no credentials. A hint that arrives
twice or never is harmless, because every byte that matters travels over the
two routes above and the database is the source of truth.

## Origins and actors

A cookie-authenticated request must send an exact HTTP(S) `Origin`. Your
configured app URL is always accepted. Add any others:

```ts
crdt: {
	namespace: "my-app",
	allowedOrigins: ["https://app.example.com"],
	engines: { text: yjsServerEngine() },
}
```

OAuth callers are authorized by the ordinary read and write scopes of the
collection or global they are editing. Machine callers go through one explicit
seam, `authenticateAgent`, which receives the request, the bearer token, the
audience and the namespace. The credential it returns must carry `crdt:read`,
may add `crdt:edit`, and must not be expired. Return `null` and the request is
refused. Whatever it returns, your normal access rules still decide the row and
the fields.

Pick one credential per request. A request carrying both a cookie and an
`Authorization` header is rejected before either is read.

<Callout type="warn" title="A short secret disables collaboration silently">
	The runtime needs `secret` to be at least 16 characters. Below that, the CRDT
	service reports itself unavailable rather than throwing.
</Callout>

## Validate and project an acknowledged cut

Use `crdt.projection.prepareAcknowledgement` when the canonical collaborative
value and application-owned projections must advance together. The callback
runs after QUESTPIE has materialized and locked one complete aggregate cut, but
before it writes canonical fields, projection cursors, owner metadata or the
realtime outbox event:

```ts
import { sql } from "drizzle-orm";
import { runtimeConfig } from "questpie/app";

export default runtimeConfig({
	db: { url: process.env.DATABASE_URL! },
	crdt: {
		namespace: "my-app",
		engines: { text: yjsServerEngine() },
		projection: {
			async prepareAcknowledgement(input) {
				const markdown = String(input.values.get("content"));
				const canonical = validateAndNormalizeMarkdown(markdown);
				const references = extractReferences(canonical);

				await input.transaction.execute(sql`
					DELETE FROM article_references
					WHERE article_id = ${input.owner.recordId}
				`);
				// Insert the exact validated relation set through the same transaction.

				return {
					values: new Map(input.values).set("content", canonical),
					ownerValues: {
						contentHash: hash(canonical),
						plainText: toPlainText(canonical),
					},
				};
			},
		},
	},
});
```

`values` is the complete authoritative aggregate cut; `changed` identifies the
CRDT fields whose cursors advanced. `contributors` identifies every Human or
verified Agent commit included since the previous projected cut, so the
consumer can recheck application authority without treating the hook as a new
grant.

Throw to reject the cut. Callback writes, canonical values, `ownerValues`,
projection cursors and the realtime event then roll back together. The callback
may be retried, so derived writes must be deterministic and idempotent. A
returned `values` map must contain exactly the same field paths and value kinds
as the input. `ownerValues` may update only ordinary owner columns; CRDT fields
and system identity/revision/timestamp columns remain framework-owned.

## The text engine

`yjsServerEngine()` merges untrusted bytes in worker threads inside the process
that already serves your API. It takes three optional numbers.

| Option                 | Default and ceiling                                                                 |
| ---------------------- | ----------------------------------------------------------------------------------- |
| `operationTimeoutMs`   | `100`, between 1 and 30,000                                                         |
| `maximumActiveWorkers` | the lower of 4 and twice your core count, which is also the maximum you may ask for |
| `maximumPendingJobs`   | `64`, which is also the maximum                                                     |

Projection and compaction run on the same schedule seam, in the API process or
in a QUESTPIE worker you already deploy. Enabling collaboration starts no new
process, and shutdown drains through the one your framework already has.

## Protocol limits

| Limit                       | Value   |
| --------------------------- | ------- |
| one field update or chunk   | 256 KiB |
| field parts in one exchange | 32      |
| one request payload         | 1 MiB   |
| proof per field             | 64 KiB  |
| awareness value             | 1 KiB   |
| a first-load artifact       | 64 MiB  |
| elements in a set field     | 10,000  |
| one set element             | 4 KiB   |

New sessions are rate limited per subject, at a burst of 30 and one token every
two seconds, and per credential, at a burst of 10 and one token every six
seconds. The browser sends at most one awareness write every 50 ms, counted
across every document it has open.

## Where the state lives

PostgreSQL 15 or newer, which is the QUESTPIE minimum anyway. Commits,
receipts, cursors, sessions, grants, snapshots and presence are all durable
rows. A message broker makes delivery faster. It is never the record.
