---
title: WebSocket
description: Connect a document to the edytor room with createWebsocketSync, and follow connection, saved state and refusals.
icon: radio-tower
---

`createWebsocketSync` connects a document to a sync server over a WebSocket, keeps a local IndexedDB copy, and syncs open tabs. It is built for the edytor [Durable Object room](/docs/server/quick-start); any server that speaks the [edytor protocol](/docs/server/protocol) works too.

## Connect

```svelte
<Edytor
  server="wss://rooms.example.com/rooms"
  room={documentId}
  params={{ token }}
  actor={{ id: userId }}
/>
```

The view dials `<server>/<room>?replica=<clientID>&<params>`, here `wss://rooms.example.com/rooms/doc-42?replica=…&token=…`. A room id is any string of 1 to 256 characters except `.`, `..` and one with a lone surrogate (half of an emoji, as `title.slice(0, 40)` can leave): it is percent-encoded (`encodeURIComponent`) into one path segment, so `/`, `%`, `#` and `?` in it reach their own room, and the server decodes it (see the [quick start](/docs/server/quick-start)). A URL collapses a `.` or `..` segment (escaped as `%2E` too), and `encodeURIComponent` cannot encode a lone surrogate, so the provider and `createWebsocketSync` throw a `TypeError` for those ids, and for an empty or longer one, instead of dialing a path no room answers. `<Edytor server room>` never throws for such an id, in the browser or in the server render: an editable view reports it through `onSyncRefused` as the `4400` refusal the server would send, and a readonly view, which never dials, renders its `value`. `replica` is the document's client id, added at every dial so the room can bind it to the user at connection time; see [Authorization](/docs/server/authorization#replicas). A `replica` in `params` overrides it. `params` is read at every dial, so a refreshed token reaches the next reconnect.

The props build a `createWebsocketSync` for you. Use the factory directly for the options below, or to share one document between several views: `createDocument({ actor })`, then `document.attachSync(createWebsocketSync(…))`, then `<Edytor {document} />` in each view.

## Options

| Prop | Type | Default | Description |
| - | - | - | - |
| `server` | `string` | - | The server's base URL (ws:// or wss://), as the `server` prop. Trailing slashes are removed. |
| `room` | `string` | - | The document's room, appended to server as one percent-encoded path segment, as the `room` prop. |
| `params?` | `Record<string, string>` | - | Query parameters such as a token. Read at every dial; `replica` defaults to the document's client id. |
| `maxBackoffTime?` | `number` | `2500` | Upper bound, in ms, of the exponential reconnect delay. A room that stays unreachable grows it to 30 s. |
| `connectTimeout?` | `number` | `10000` | How long, in ms, a dial may take to open before it is closed like a failed one, which starts the readiness bound. Raise it for rooms whose load takes longer; `Infinity` waits as long as the browser does. |
| `onExpired?` | `({ reason, attempts, nextRetryMs }) => void` | - | The room closed the socket with `4401` (expired credentials). Put a fresh token in `params` before the redial, due in `nextRetryMs`. `<Edytor>` takes it as `onSyncExpired`. |
| `disableBc?` | `boolean` | `false` | Turn off cross-tab sync over BroadcastChannel, for both the socket and the local copy. |
| `persist?` | `boolean` | `true` | Keep a local IndexedDB copy. Skipped where there is no indexedDB. |
| `persistName?` | `string` | `"edytor:<server>/<room>"` | Name of the local copy. Also returned on the sync as sync.persistName, for clearDocument. |
| `WebSocketPolyfill?` | `typeof WebSocket` | - | A WebSocket implementation for environments without a global one. |

`serverUrl` and `roomName`, the names of earlier releases, still work in place of `server` and `room`.

`params` is kept by reference and read each time the provider dials, so you can refresh a short-lived token by updating the object you passed:

```ts
const params = { token: await getToken() };
const sync = createWebsocketSync({ server, room, params });
setInterval(async () => (params.token = await getToken()), 10 * 60_000);
```

With `<Edytor>`, refresh when the room reports the expiry: the `params` prop reaches the next dial.

```svelte
<Edytor {server} {room} params={{ token }} onSyncExpired={async () => (token = await getToken())} />
```

Browsers cannot set headers on a WebSocket, so tokens travel in `params` (or in cookies your `authorize` reads).

## Reconnects

- On a lost connection the provider reconnects with exponential backoff: `100 ms × 2ⁿ`, capped at `maxBackoffTime`. `n` counts the dials in a row that never synced (refused upgrades, sockets that closed before the handshake finished); a dial that synced starts it over. Each dial that ends before it synced emits `unreachable` with `{ attempts, nextRetryMs }`, for a "Reconnecting in 2 s" indicator; a `4401` close emits `expired` instead, with the same fields. A room fault (`1011`) after the sync counts too, until the room acknowledges every local update again, so a fault that persists is not redialed every 100 ms.
- After 8 such dials the cap doubles with each further one, up to 30 s, so an unreachable server, or one that refuses the upgrade with an HTTP error, is not dialed forever at full rate.
- A close with a refusal code (`1008`, or `4000`–`4999`) is terminal: the provider stops dialing and emits `refused` (see [refusals](#refusals)). `4401` is the exception: the credentials expired, so the provider emits `expired` and redials after the backoff, reading `params` again. Put a fresh token in `params` when `expired` fires (`onExpired`, or `onSyncExpired` on `<Edytor>`). Before the first sync, an empty document does not seed meanwhile: it waits for a dial that gets in.
- A dial that neither opens nor fails within `connectTimeout` (10 seconds by default; a connection the network silently drops, or a room still loading) is closed and counted like a failed dial: it emits `unreachable`, starts the readiness bound, and redials after the backoff.
- A connection that receives nothing for 30 seconds is treated as dead and reopened. After 15 seconds of silence the provider sends one text `ping` frame; the edytor room answers `pong` without waking from hibernation, and a text `pong` counts as heard. The room also echoes each presence renewal (every 15 seconds), so even a client alone in its room hears from it in time. A server of your own should answer `ping` with `pong`, or ignore text frames.
- Each (re)connection starts with a handshake in which both sides send what the other lacks. Nothing is replayed by hand and no periodic resync is needed.
- While disconnected, other people's carets disappear from this tab; edits keep going to the local copy. Other tabs of the browser keep the carets they still receive. On reconnect the edytor room sends the peers present in the room, and their carets show at once.

## Provider events and state

`createWebsocketSync` creates the provider internally. To observe it, write a sync factory around the exported `WebsocketProvider`; this one behaves like `createWebsocketSync` (socket, local copy, one channel) and reports its state:

```ts
import {
  WebsocketProvider,
  createIndexeddbSync,
  type EdytorSync,
  type EdytorSyncPayload
} from 'edytor';

export const observedSync = (
  server: string,
  room: string,
  params: Record<string, string>,
  onState: (state: { status: string; saved: boolean; unsaved: number }) => void
): EdytorSync =>
  Object.assign(
    ({ doc, awareness, synced, failed, attach }: EdytorSyncPayload) => {
      // The local copy carries the cross-tab channel, so the socket's is off.
      const local = attach?.(createIndexeddbSync(`edytor:${server.replace(/\/+$/, '')}/${room}`));
      const provider = new WebsocketProvider(server, room, doc, {
        awareness,
        params,
        disableBc: true
      });
      let status = 'connecting';
      const report = () => onState({ status, saved: provider.saved, unsaved: provider.unsaved });
      provider.on('status', (event) => {
        status = event.status;
        report();
      });
      provider.on('saved', report);
      provider.on('synced', (isSynced) => isSynced && synced(provider));
      provider.on('failed', (error) => failed?.(error, provider));
      provider.on('permission-denied', (reason) => console.warn('denied:', reason));
      provider.on('schema-mismatch', (detail) => console.warn('refused content', detail));
      return () => {
        provider.destroy();
        return local?.();
      };
    },
    { target: `websocket:${server}/${room}` }
  );
```

This factory keeps the default readiness bound, counted from the attach. `createWebsocketSync` instead sets `bound: Infinity` and calls the payload's `armBound()` when the socket opens and on its first failed dial, so time spent dialing a slow room never counts, and `holdBound()` on a `4401` before the first sync (see [readiness](/docs/collaboration/documents#readiness)).

| Event | Payload | When |
| --- | --- | --- |
| `status` | `{ status: 'connecting' \| 'connected' \| 'disconnected' }` | The socket's state changed. |
| `synced` | `boolean` | This connection has (`true`) or has lost (`false`) the room's state. Resets with every connection. |
| `saved` | `({ saved, unsaved }, provider)` | The number of local updates the room has not stored yet changed. |
| `permission-denied` | `(reason, provider)` | The server refused a write this socket sent. The edytor room sends `read-only` for each edit of a read-only socket; that denial is not a failure (the socket syncs), and the provider stops counting unsaved updates. It never fires for merely joining: a read-only socket that sends nothing hears none (see `readOnly`), and once the room said so, the provider sends it only this document's own edits, never what it heard from another tab or restored from the local copy. Any other reason is terminal, as `failed`. |
| `refused` | `(refusal, provider)` | The server closed the socket with a refusal code. `refusal` is a `SyncRefusedError` with `code` and `reason`. The provider no longer dials. |
| `expired` | `({ reason, attempts, nextRetryMs }, provider)` | The server closed the socket with `4401`. The provider redials in `nextRetryMs` with the current `params`; refresh the token before then. |
| `unreachable` | `({ attempts, nextRetryMs }, provider)` | A dial ended before its connection synced, or did not open within `connectTimeout`: `attempts` in a row, the next one in `nextRetryMs`. Also when the room closes a synced socket with `1011` (it could not store an update): `attempts` counts those faults since the room last stored every local update. |
| `schema-mismatch` | `(detail, provider)` | An update carrying a foreign schema stamp was refused and not applied. |
| `protocol-mismatch` | `(mismatch, provider)` | A frame of another edytor generation arrived and was dropped. |
| `failed` | `(error, provider)` | Terminal: the provider never synced (destroyed first, refused, or denied). Emitted at most once. |
| `connection-close`, `connection-error` | the socket event | The socket closed or errored. The provider reconnects on its own. |
| `message-error` | `(error, provider)` | A frame could not be read or applied; it was dropped. |

A listener that throws is logged with `console.error`, and the other listeners and the provider keep running: a failing `onExpired` still gets the redial, and the empty document still waits for it.

| Property | Description |
| --- | --- |
| `synced` | The current connection holds the room's state. |
| `hasSynced`, `whenSynced` | The provider has synced at least once, and a promise for it. |
| `saved` | `true` when the room has stored every update this document's actor wrote. |
| `unsaved` | How many of those updates the room has not stored yet, offline edits included. |
| `readOnly` | `true` once the room said this connection may read but not write (a notice when it joins, no event). `false` again when a later dial may write. |
| `wsconnected` | The socket is open. |
| `connect()`, `disconnect()`, `destroy()` | Control the connection. `destroy()` also announces your departure to peers. |

## Saved state

The edytor room stores every update in SQLite before it acknowledges it. The provider compares those acknowledgements with what this replica wrote:

- `unsaved` counts updates the room has not confirmed that this document's actor wrote: in this session, in this user's other tabs, and before a reload (edits restored from the local copy). The document binds each client id to its actor, so the provider knows which ids are the user's own. Give the document an `actor`: an anonymous one gets a new id per session, so its edits from before a reload no longer count. A seed this replica wrote counts too. The binding records themselves are bookkeeping and never count. A read-only socket counts nothing: the room sends it a read-only notice when it joins (no `permission-denied`), and its provider sets `readOnly`, drops what it counted and reports `saved`, whatever the local copy, the other tabs or a seed hold. Edits the room never stored (made offline before the access changed, say) stay unstored and are not sent: to warn the user, check `readOnly` in the `'saved'` listener, and remember `unsaved` from the event before it (an `unsaved` above zero dropped to `0` with `readOnly` set means those edits were not stored). An edit made while read-only is refused, one `permission-denied` per frame. If a later dial of the same provider may write (a refreshed token), what the actor wrote counts again until the room stores it. A provider on a bare engine doc, with no actor binding, counts everything the doc holds.
- `saved` is `true` once all of those are confirmed. Content written by other actors never counts, even when it only reached the room through this client: after a restore from an older snapshot, the room strips another user's edits it no longer holds, and they stay stripped until their author returns. Deleting such content is stored at once: the delete waits in the room and applies when the author's edit returns, so the text does not come back. A delete counts as saved only once the room names it in an acknowledgement; if the room already holds its limit of waiting deletes (`MAX_WAITING_DELETES`, 1,024 ranges), it drops the delete, which stays unsaved (and the text comes back when its author returns) until a later connection resends it and the room stores it. An edit of a block whose last change the restore lost stays unsaved until that change's author returns: every edit rewrites the block's last-changed-by attribute, and that rewrite builds on the lost change (see [restoring from an older snapshot](/docs/server/authorization#restoring-from-an-older-snapshot)). A delete that adds no new data (undoing a block deletion, for example) stays unsaved until the room confirms that exact delete. A delete restored from the local copy counts only if its update wrote no other actor's content, since a delete does not record who made it.
- The `'saved'` event fires with `{ saved, unsaved }` whenever either changes.

Use it for a "Saving… / Saved" indicator or to warn before closing a tab (a `beforeunload` prompt: when the user stays, the providers keep running). A server that sends no acknowledgements (another relay, for example) leaves `unsaved` counting forever.

## Refusals

| What happens | Cause | What the client sees |
| --- | --- | --- |
| Close `4401` | The credentials expired. | Not terminal: `expired` fires and the provider redials after the backoff, reading `params` again. No `refused`, no `failed`. A document still empty is not seeded meanwhile: it waits for a dial that gets in, and stays `pending` while the token is not refreshed. |
| Close `4403` `document access denied` | `authorize` returned `null`, 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, or a `replica` that is not a client id. | Terminal: `refused` fires once and the provider stops dialing. Refresh the token, then reload (or call `connect()`) to dial again. An empty document waits for that dial: it seeds only once the provider syncs, or is released while another attached provider has settled. A document that already holds content (a local copy, bytes it was attached over) is decided at the refusal, and a view shows it. |
| Close `4409` `replica bound to another user` | The replica belongs to another user. | Terminal: `refused` fires and the provider stops dialing. |
| Read-only notice (auth subtype `1`) | The socket is read-only; sent when it joins. | No event: `readOnly` turns `true` and `saved` reports `true`. The socket keeps syncing. |
| `permission-denied` with `read-only` | The read-only socket sent an edit. | The socket stays open and keeps syncing; the edit is not stored, and `saved` stays `true`. |
| Close `1008` `refused: <reason>`, or another `4xxx` code | A frame of another generation, a presence entry for a client id another user owns, a foreign schema stamp, or a malformed frame. Content under another user's client id is not a refusal: the room strips it and the socket stays open. | Terminal: `refused` fires and the provider stops dialing. A document still empty is not seeded; it stays `pending` and reports the refusal as `document.syncRefusal` / `onSyncRefused`. Reload with a fixed client (a new deploy, a new token) to try again. |
| Close `1011` | The room could not store an update. | The provider reconnects and the handshake resends the edit, backing off while the fault repeats; each such close emits `unreachable`. |

`<Edytor>` reports a refusal of its document through `onSyncRefused(refusal)`: one already standing when the view mounts, then each new one.

See [Authorization](/docs/server/authorization) and [the room](/docs/server/room) for the server side.
