---
title: Troubleshooting
description: What each thrown error means, how a sync connection ends and why, and the refused status a command returns instead of throwing.
icon: life-buoy
---

Edytor reports problems three ways: it **throws** when you misuse an API, a **provider** reports how a connection ended through events and the document, and a **command** that cannot apply returns a `refused` status instead of throwing. This page lists each with its cause and fix.

## Thrown errors

| Error | Thrown by | Cause and fix |
| --- | --- | --- |
| `DocumentNotReadyError` | `document.history` | The document is still `pending`: a provider may still bring content. Wait for [`onReady`](/docs/collaboration/documents#readiness), or call `document.sync()`. |
| `DocumentDestroyedError` | any document member, `attachSync` | The document was destroyed. A view destroys only the document it created itself. |
| `SemanticConflictError` | `createDocument`, a view mounting | Two sources declare different block rules (role, default child, default type) for one kind: two views with different plugins, two plugins in one view, or a headless `semantics` option a view's plugins contradict. Give every view of a document the same plugins. |
| `UndecodableUpdateError` | `loadDocument` | The bytes are not an update. Your bytes are never modified. |
| `UnsupportedDocError` | `loadDocument` | Not an edytor document; with reason `legacy`, a v13 (`yjs`) document: [migrate it](/docs/reference/migration#importing-v13-documents). |
| `SchemaMismatchError` | `loadDocument` | The content claims a schema this build cannot own (a newer build wrote it). |
| `GenerationMismatchError` | `migrate`; reported, not thrown, by an IndexedDB store (the provider's `failed`) and by the room (its `failure`) | A store of another schema generation. The v14 development generations 1–3 are not migrated: re-import them from JSON. For a room, `reset()` drops the stored container and starts again from `onLoad` ([generation cutover](/docs/server/room#generation-cutover)). |
| `TypeError: room id "…" cannot be dialed` | `WebsocketProvider`, `createWebsocketSync` | The room id is empty, `.`, `..`, over 256 characters, or holds a lone surrogate. A URL collapses a `.` or `..` segment, and cannot encode a lone surrogate, so no dial could reach that room. Pick another id. (`<Edytor server room>` does not throw: it reports the `4400` refusal below.) |
| `EdytorDocDisposedError` | the facade | The facade was used after its document was destroyed. |
| `Error: [edytor-doc] stale plan` | `facade.apply(plan)` | The plan was prepared before another write. Prepare and apply in the same turn, or `compose` plans prepared at one version. |
| `Error: No Edytor found` | `useEdytor()` | Called outside a component rendered inside `<Edytor>`. |
| `Error: EdytorOptions: document cannot be combined…` | `new Edytor(…)` | Pass either `document` or `doc`/`awareness`/`actor`, not both. |

[Documents](/docs/collaboration/documents#errors) lists the document errors with their options.

A block kind no plugin registers does not throw: it renders as a plain block (its text and children) and logs one warning per kind in development. A chord such as `cmd+s` does not throw either: it fails type-checking and warns in development ([chord syntax](/docs/customization/hotkeys#chord-syntax)).

## Sync connections

The WebSocket provider reports how a connection ended through its [events](/docs/collaboration/websocket#provider-events-and-state), and the document keeps a terminal refusal as `document.syncRefusal` / `onSyncRefused`. What the client sees depends on the close code:

| Close | Means | The client |
| --- | --- | --- |
| `1006`, or no connection | The server is unreachable, or refused the upgrade with an HTTP error, which a browser cannot tell from a network failure (a Worker route that does not match the room path, say: the provider sends the room id as one percent-encoded path segment). | Keeps redialing with a growing backoff (up to 30 s) and emits `unreachable` with `{ attempts, nextRetryMs }`. `params` are re-read at each dial, so a refreshed token gets in. If your Worker turns the dial away itself (an unknown document, an origin it does not serve), answer with [`closedSocket`](/docs/server/authorization#authorize) and a `4xxx` code, so the client stops. |
| `4400` `invalid document id` | The room name is empty, `.`, `..`, longer than 256 characters or holds a lone surrogate (`routeDocumentSocket` never calls `authorize`). The provider refuses such a name itself, with a `TypeError` at construction; `<Edytor server room>` reports it as this refusal through `onSyncRefused` without dialing. | Terminal, like `4403`. |
| `4401` `expired` | Your `authorize` returned `{ expired: true }`: the credential expired. | Not a refusal: emits `expired` and redials after the backoff, reading `params` again. Put a fresh token in `params` when `expired` fires (`onSyncExpired` on `<Edytor>`). |
| `4403` `document access denied` | Your `authorize` returned `null` for this user and document, or an [invalid identity](/docs/server/authorization#authorize): a `userId` that is empty or not a string, over 256 characters or with a lone surrogate (a name cut mid-emoji), or a `replica` that is not a client id. | Terminal: emits `refused` and stops dialing. An empty document stays `pending`. Fix the permission, then reload. |
| `4409` `replica bound to another user` | The client dialed with a client id another user owns (a browser profile shared by two users). | Terminal, like `4403`. See [replicas](/docs/server/authorization#replicas). |
| `1008` `refused: <reason>` | The room refused this client for good; the reason is one of `generation` (a build of another schema generation), `replica` (a presence entry for a client id another user owns), `schema` (a foreign schema stamp), `identity`, `malformed` (bytes the room cannot decode), or `container` (the room's stored container is of another generation, torn, or its `onLoad` returned content it cannot use). Content an update adds under another user's client id is not a refusal: it is stripped and the socket stays. | Terminal, like `4403`. Every socket of a room with a `container` refusal is refused until an operator fixes the storage (for a generation mismatch, [`reset()`](/docs/server/room#generation-cutover)). |
| Any other `4xxx` | An application refusal from your own server. | Terminal, like `4403`. |
| `1011` `storage failure` | The room could not store an update or a client-id registration. | Redials; the handshake resends the edit. While the fault repeats, each `1011` doubles the backoff and emits `unreachable`, until the room acknowledges the edits. |
| `1011` `internal error` | The room's CRDT engine or a send failed; the room rebuilt its document from storage. | Redials, backing off the same way; the handshake resends the edit. |
| `1011` `room unavailable` | The room's `onLoad` threw, or the room could not read its rows (when it started, or after a storage failure). | Redials; the room loads again at the next dial. |
| `permission-denied` message (`read-only`) | The read-only socket sent an edit (joining alone sends a notice that sets `provider.readOnly`, not this event). | The socket stays open and syncs; the edit is not stored. Render read-only users with a read-only view. |

Before a document has content, a terminal refusal never seeds its `value`: the document stays `pending` and `document.syncRefusal` holds the `SyncRefusedError` (`code`, `reason`). A document that already has content keeps it and stays editable offline.

On the server, `room.refusals` keeps the newest 100 refusals with their detail and `refusalCounts` counts them by reason; see [diagnostics](/docs/server/room#diagnostics).

Other provider signals:

- `schema-mismatch`: an update carrying a foreign schema stamp was dropped; the connection stays.
- `protocol-mismatch`: a frame of another generation arrived and was dropped.
- `document.writable === false`: the document holds content from a schema this build cannot own. Every write is refused and providers stop sending until it heals.
- `unsaved` never reaches `0`: the server sends no store acknowledgements (a relay that is not the edytor room). Or it stays above `0` after deleting text a restore lost: the room holds its limit of waiting deletes and dropped this one (a `waiting` entry in the room's `refusals`); the next connection resends it (see [the room's storage](/docs/server/room#storage)). If the limit stays full, `dropWaitingDeletes()` on the room empties it (a class that uses `attachDocument` forwards it as its own method: RPC does not reach the returned document).

## Refused commands

Editing commands never throw because they cannot apply. They report a status:

- `edytor.dispatcher.last.status` after a view command: `applied`, `noop` (it ran and changed nothing), `refused` (nothing was written and no undo step recorded) or `failed` (a hook or the command threw; the error is rethrown and kept in `last.error`).
- An [`OpResult`](/docs/reference/document-api#results) from a facade operation: `applied`, `noop` or `refused`, with a `reason` when the document names one (`id-collision`).

A command is refused when:

- the view is `readonly`, or the document is not `writable`;
- a plugin's `onBeforeOperation` called `prevent()` on the command or on one of its steps;
- the document forbids it: a void block takes no children and never merges or splits, an island's blocks never leave it and nothing moves into it, a block would sit directly in a list it is no item of (a move or outdent of an image there; see [containers](/docs/concepts/blocks#containers)), a target is missing or deleted, or an id already exists.

Check `last.status` after a command you issue when the next step depends on it:

```ts
block.setData({ ...block.data, checked: true });
if (edytor.dispatcher.last?.status === 'refused') showReadonlyNotice();
```

## Installing the pre-release

| Symptom | Cause | Fix |
| --- | --- | --- |
| `ERR_PNPM_TARBALL_INTEGRITY` or `EINTEGRITY` on an install of the hosted tarball | Your lockfile pins the hash of other bytes than the ones served at that URL. The site never replaces a tarball under the same version (a new build gets a new pre-release version and URL), so this means the lockfile entry came from elsewhere, or from a forced redeploy. | Remove the package, then install the URL again, which records the served hash: `pnpm remove edytor && pnpm add <that URL>` (npm: `npm uninstall edytor && npm install <that URL>`). With pnpm, installing the same URL without removing it first keeps the pinned hash and fails the same way. Commit the lockfile. To move to a newer pre-release, install its URL from [Install](/docs/getting-started#install). |
| `ERR_PNPM_FETCH_404` (or npm's `404 Not Found`) on a hosted tarball URL | The site never hosted that version: a typo in the URL, or a version from a local build. Every pre-release it has served keeps answering. | Install the URL from [Install](/docs/getting-started#install). |
| After an upgrade, a room opens empty while clients not yet upgraded keep editing it | Your Worker routes on the raw path segment. Since 0.1.0-next.1 the provider percent-encodes the room id (`a:b` dials `…/rooms/a%3Ab`), so an id holding `$`, `%`, `&`, `+`, `,`, `:`, `;`, `=`, `@`, `[`, `]` or `\|` names another Durable Object: an older client sent those characters unescaped (`50%off` against `50%25off`; Chromium already escaped `\|`). (An id holding `/`, `\`, `?`, `#`, a tab or a line break never reached its own room from an older client at all.) | Decode the segment as the [quick start](/docs/server/quick-start) does, and deploy the Worker with the upgraded clients; see [upgrading between pre-releases](/docs/reference/migration#upgrading-between-pre-releases). |
