# Multiplayer Jodit: how the collaboration mode works under the hood

We shipped real-time collaboration for Jodit. Several people in one document, live cursors with name flags, threaded comments pinned to the text, and a Google-Docs-style suggesting mode where edits become proposals you accept or reject. You can [try it right now](https://xdsoft.net/jodit/pro/collaboration/demo) with a friend, or just open the page in two tabs.

This post is about how it actually works: what travels over the wire, why the server decides who you are, and how we wired the public demo so every editor on our site is quietly multiplayer.

## The problem with contenteditable

Every collaborative editor faces the same choice. You can throw away the browser's editing engine and rebuild it on top of your own document model, which is what most modern collaborative editors do, and which costs years. Or you can keep contenteditable and try to make sense of what the browser does to the DOM.

We kept contenteditable. Jodit has been a native-editing WYSIWYG for over a decade, and its plugin ecosystem assumes the real DOM. Replacing the engine would have meant a different product.

So the plugin watches instead of controlling. A MutationObserver sees every change the browser (or any Jodit plugin, or paste, or drag and drop) makes, and a capture session normalizes the raw mutation records into a short list of patches. Every DOM node gets a stable id, and patches address nodes by id, never by path or offset from the root:

```jsonc
// what actually travels over the WebSocket
{ "op": "text",   "nodeId": "a12", "index": 5, "remove": 0, "insert": "k" }
{ "op": "insert", "parentId": "a3", "afterId": "a11", "node": { /* subtree */ } }
{ "op": "attr",   "nodeId": "a7",  "name": "data-jodit-comment", "value": "c1" }
{ "op": "remove", "nodeId": "a9" }
```

No HTML strings ever cross the network. That single decision pays for itself everywhere: patches are tiny, they compose under operational transformation, and the server can reason about them.

The hot path skips the observer entirely. Plain typing is intercepted at `beforeinput`, expressed as a patch first, then applied to the DOM through our own reconciler. The browser never edits the document on that path, which kills a whole class of "the browser normalized my whitespace" bugs. Anything the interceptor does not claim (IME composition, paste, complex selections) falls through to the native engine and gets captured after the fact.

## The server is the boss

Convergence comes from an authoritative sequencer. Every client sends patches optimistically, the server assigns each batch a sequence number, transforms it against anything it hasn't seen, and broadcasts the result. Reconnects ask for "everything since seq N" and get a compact delta, not a full document.

The stack is TypeScript end to end: three npm packages (`@jodit/collab-protocol` with the patch types, OT transform and zod message schemas, `@jodit/collab-server`, and the `@jodit/collab-plugin` for the editor), WebSockets for the session, REST for history and exports. Storage is a small adapter contract: in-memory for demos, PostgreSQL for production. The whole server ships as a Docker image, so self-hosting is a compose file.

One part I would defend in any argument: the content firewall. The server runs an allowlist sanitizer (built on the `sanitize-html` library, not a hand-rolled regex) over every incoming batch. If anything would be stripped, the whole submit is rejected and the client resyncs. One compromised browser cannot inject markup into everyone else's document, because the server refuses to relay it.

Wiring it into a page is deliberately boring:

```javascript
import { Jodit } from 'jodit';
import collab from '@jodit/collab-plugin';

Jodit.plugins.add('collab', collab);

Jodit.make('#editor', {
    collab: {
        url: 'wss://collab.example.com/collab/ws',
        docId: 'report-42',
        token: await getSessionToken()
    }
});
```

That's the whole integration. The plugin registers its toolbar buttons, mounts the panels, connects, and exposes a controller on `editor.collab` if you want programmatic access.

## See it, don't take our word

This is not a screenshot. It's a live editor connected to the same shared room as the mini demo on our landing page; whoever is reading this post or browsing the landing right now is in the document with you. The avatars above the toolbar are real people (or you, in a second tab):

`{example CollabLive}`

The pre-seeded thread and suggestions come back every few hours when the demo resets, so feel free to accept, reject and break things.

## Comments live in the document, not next to it

Most implementations store comment positions as external coordinates: node path plus offset. Those break the moment someone else edits the paragraph. We went the other way: the anchor is ordinary content.

```html
Marketing will coordinate with the
<span data-jodit-comment="c-181">partner team</span> a week ahead.
```

The span travels through the same patch pipeline as any other edit, so it moves with the text, survives HTML export, and needs zero special casing in the OT layer. The thread itself (author, replies, resolved flag) lives on the server; the two halves pair by id. Delete the anchored text and the thread doesn't vanish, it just shows an "unanchored" badge in the panel with the original quote.

Suggestions reuse the exact same trick with `data-jodit-suggestion` and a type attribute: insertions render green-underlined, deletions struck through. Accepting or rejecting is server-side: the server derives the unwrap-or-remove patches from its own copy and pushes them through the normal sequencer, so a resolution converges on every screen like any other edit.

Suggesting mode was the fun one to build. Jodit's own backspace plugin handles the key at `keydown` and calls preventDefault, which means `beforeinput` never fires for deletions. The only reliable interception point is a capture-phase listener on the document, ahead of everything. While the mode is on, Backspace doesn't delete: the would-be-deleted character gets wrapped into a delete-suggestion span and the caret steps over it. Hold Backspace and the span grows, still one suggestion.

## Authorization: the server names you

This is the rule we refuse to bend: identity is always server-assigned. The client sends an opaque token and nothing else. The zod schemas literally have no field for a client-sent display name, so impersonation isn't a validation problem, it's a protocol impossibility.

Your backend decides what a token means through one hook:

```javascript
export default {
    auth: {
        async checkAuthentication(token) {
            const session = await verifyJwt(token);

            return {
                userId: session.sub,
                name: session.displayName,
                color: pickColor(session.sub),
                role: session.canEdit ? 'writer' : 'commenter'
            };
        }
    }
};
```

Four roles gate every action per document: `owner` moderates and resolves, `writer` edits, `commenter` can propose suggestions and comment but not touch the text, `reader` watches. A nice consequence: a commenter-role reviewer can fill your document with suggested edits that a writer later applies with one click. Tokens can also be a provider function on the client, so JWT refresh survives reconnects.

## How the demo is built

The demos on [xdsoft.net](https://xdsoft.net/jodit/pro/) are not mockups; every preset editor on the site is connected to one public demo server. It's the published Docker image with a single bind-mounted config file, memory storage, behind nginx. Each demo page has its own room, so the classic demo and the document demo never bleed into each other.

Identity works exactly like production, just with a twist. The page generates a UUID once, keeps it in localStorage, and sends it as the token. The server hashes it (FNV-1a) into a stable friendly name and color:

```javascript
checkAuthentication(token) {
    const h = fnv1a(String(token || 'guest'));
    const name = `${ADJECTIVES[h % 20]} ${ANIMALS[(h / 97 | 0) % 20]}`;

    return { userId: token, name, color: COLORS[(h / 31 | 0) % 12], role: 'owner' };
}
```

So you're "Amber Fox" today, and the same "Amber Fox" next week, without an account. Names come from the server even in the demo, because the demo should demonstrate the security model, not shortcut it.

Fresh rooms get seeded twice. The server seeds the conversation metadata: a comment thread where one persona asks a question and another replies, plus a couple of pending suggestions, so the first curious visitor sees a living document instead of an empty one. The page seeds the content itself, with anchor spans whose ids pair with that metadata. And since storage is in-memory, a cron job restarts the container every four hours: whatever chaos visitors typed, the demo resets itself to the seeded state. The demo page shows a countdown to the next wipe, computed client-side, because the reset boundaries are exact multiples of four hours since the Unix epoch.

The mini demo on the [collaboration landing page](https://xdsoft.net/jodit/pro/collaboration/) started life as a static mock. It's now the real editor in a small card, with presence avatars sitting above the toolbar. That placement needed a new workplace slot in Jodit core, because the toolbar used to insist on being the container's first child. Small change, but it's the spot where a presence bar belongs.

## Try it

The [live demo](https://xdsoft.net/jodit/pro/collaboration/demo) is a real shared document; open it twice and watch your own cursors. The [docs](https://xdsoft.net/jodit/pro/collaboration/docs/getting-started.md) walk through a full setup, from `docker run` to a JWT-authenticated production deployment. Collaboration ships with the Enterprise plan, cloud or fully on-premise: your server, your storage, your documents.

_Full page: https://xdsoft.net/blog/jodit-collaboration-how-it-works_
