Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 13 of 15AdvancedAug 3, 2026

Collaborative Document

Design real-time collaborative editing, comparing Operational Transformation and CRDTs on concrete conflicting edits, with presence, local-first application, and offline merge.

frontend-system-designpractice-problemcollaboration

The Problem

Design a document that several people edit at the same time - a shared notes page, a spec, a wiki article. Each person sees the others' changes within a moment, sees their cursors, and nobody's work is lost. It must keep working when one of them goes offline and comes back.

This is the hardest problem in this track because concurrency is unavoidable. Every other design can serialise conflicting writes by taking the last one. Here, taking the last write means throwing away someone's paragraph. The whole design is about a merge function that produces an identical result on every replica, no matter what order the changes arrived in.

Everything from Rich Text Editor is a prerequisite: a structured document model, changes expressed as transactions, and positions that survive re-rendering. An editor that mutates the DOM directly has no representation of "what changed" to send anywhere, so collaboration is unreachable from that architecture.

Requirements

Functional

  • Multiple concurrent editors, with changes visible in under a second.
  • Presence: who is here, and where their cursor is.
  • Offline editing, merged without loss on reconnect.
  • Version history and the ability to restore a previous state.
  • No lost edits under any interleaving.

Non-functional

  • Typing is never gated on the network - local application is immediate.
  • All replicas that have seen the same changes are byte-identical.
  • Reconnection after a long absence resolves without manual intervention.
  • Document size and history growth bounded over years of editing.

The Convergence Problem

Two users. The document contains hello. Alice inserts X at index 5; Bob inserts Y at index 5. Both operations are correct against the state each user saw.

Naive application produces divergence: Alice applies her own change to get helloX, then receives Bob's insert-at-5 and produces helloYX. Bob does the mirror and produces helloXY. Both users are now looking at different documents, and no further message will fix it. This is the failure that both OT and CRDTs exist to prevent.

Diagram
100%
flowchart TB START["Both replicas: 'hello'<br/>Alice inserts 'X' at 5<br/>Bob inserts 'Y' at 5 (concurrently)"] subgraph NAIVE["NAIVE - apply as received"] N1["Alice: hello -> helloX<br/>then apply Bob's insert(5,'Y')<br/>-> helloYX"] N2["Bob: hello -> helloY<br/>then apply Alice's insert(5,'X')<br/>-> helloXY"] N3["DIVERGED - permanently.<br/>No later message repairs this."] N1 --> N3 N2 --> N3 end subgraph OT["OT - transform positions through a server ordering"] O1["Server accepts Alice's insert(5,'X') first.<br/>Document is now 'helloX'."] O2["Bob's insert(5,'Y') is transformed<br/>against insert(5,'X'):<br/>tiebreak by site id -> shift to index 6"] O3["Server applies insert(6,'Y')<br/>-> 'helloXY'"] O4["Alice receives insert(6,'Y') -> helloXY<br/>Bob receives ack + transformed op -> helloXY"] O5["CONVERGED - but only because ONE<br/>server imposed the ordering and did<br/>the transforming."] O1 --> O2 --> O3 --> O4 --> O5 end subgraph CRDT["CRDT - identity per character, no positions"] C1["'hello' = characters with ids<br/>h@a1 e@a2 l@a3 l@a4 o@a5"] C2["Alice: insert X with id x@alice:7,<br/>after o@a5<br/>Bob: insert Y with id y@bob:3,<br/>after o@a5"] C3["Both replicas hold BOTH insertions,<br/>both anchored after o@a5"] C4["Deterministic tiebreak on id<br/>(e.g. lexicographic on site:counter):<br/>every replica orders them the same way"] C5["CONVERGED - with no server<br/>involvement in the decision at all."] C1 --> C2 --> C3 --> C4 --> C5 end START --> NAIVE START --> OT START --> CRDT style NAIVE fill:#3f1e1e,stroke:#ef4444 style OT fill:#1e3a5f,stroke:#3b82f6 style CRDT fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

Operational Transformation

OT keeps integer positions and fixes them up. An operation arriving from another client is transformed against every operation applied since the state that client had.

type TextOperation =
  | { kind: 'insert'; position: number; text: string; siteId: string }
  | { kind: 'delete'; position: number; length: number; siteId: string };

/**
 * Rewrite `incoming` so it applies correctly to a document that has already
 * had `applied` applied to it. Must be defined for every pair of kinds.
 */
function transform(incoming: TextOperation, applied: TextOperation): TextOperation {
  if (incoming.kind === 'insert' && applied.kind === 'insert') {
    // Strictly after: shift by the inserted length.
    if (applied.position < incoming.position) {
      return { ...incoming, position: incoming.position + applied.text.length };
    }

    // Same position: a deterministic tiebreak is mandatory. Without it, the two
    // sites resolve the tie differently and diverge - the exact bug OT exists
    // to prevent.
    if (applied.position === incoming.position) {
      return applied.siteId < incoming.siteId
        ? { ...incoming, position: incoming.position + applied.text.length }
        : incoming;
    }

    return incoming;
  }

  if (incoming.kind === 'insert' && applied.kind === 'delete') {
    if (applied.position + applied.length <= incoming.position) {
      return { ...incoming, position: incoming.position - applied.length };
    }
    if (applied.position < incoming.position) {
      // The insert point was inside the deleted range - clamp to its start.
      return { ...incoming, position: applied.position };
    }
    return incoming;
  }

  // delete-vs-insert and delete-vs-delete follow the same shape, with
  // delete-vs-delete additionally needing overlap arithmetic.
  return transformDelete(incoming, applied);
}

Two things to take from this. The tiebreak on siteId is not a detail - without a rule both sites apply identically, they resolve the tie in opposite directions and diverge. And the function must be total: every pair of operation kinds, including partial overlaps and inserts landing inside deleted ranges. For plain text that is four cases; for a rich text model with marks, block splits, list nesting and embedded objects, it is dozens, and each must satisfy the convergence property. This combinatorial growth is OT's real cost.

OT also requires a central server. Each client's operation is transformed against the server's accepted history, and the server's ordering is authoritative. Two clients transforming independently against different histories can reach different results - which is why peer-to-peer OT is not a practical architecture.

CRDTs

A CRDT abandons integer positions. Every inserted character gets a globally unique id and is anchored relative to its neighbour rather than at an index.

/** Unique and totally orderable across all sites without coordination. */
type CharId = { siteId: string; counter: number };

type Char = {
  id: CharId;
  value: string;
  /** The character this one was inserted after. null = document start. */
  after: CharId | null;
  /** Tombstone: deleted characters must remain as anchors for other ops. */
  deleted: boolean;
};

function compareIds(a: CharId, b: CharId): number {
  // Any total order works, as long as EVERY replica uses the same one.
  return a.counter - b.counter || (a.siteId < b.siteId ? -1 : a.siteId > b.siteId ? 1 : 0);
}

/**
 * Insert into the ordered character list. Concurrent inserts sharing the same
 * `after` anchor are ordered by id, so every replica reaches the same sequence.
 */
function integrate(chars: Char[], incoming: Char): Char[] {
  if (chars.some((char) => compareIds(char.id, incoming.id) === 0)) {
    return chars; // idempotent: replays and duplicate deliveries are free
  }

  const anchorIndex = incoming.after
    ? chars.findIndex((char) => compareIds(char.id, incoming.after!) === 0)
    : -1;

  let insertAt = anchorIndex + 1;

  // Skip past concurrent siblings with a higher id, so ordering among them is
  // decided by the id comparison rather than by arrival order.
  while (
    insertAt < chars.length &&
    compareIds(chars[insertAt].id, incoming.id) > 0 &&
    sameAnchor(chars[insertAt], incoming)
  ) {
    insertAt += 1;
  }

  return [...chars.slice(0, insertAt), incoming, ...chars.slice(insertAt)];
}

/** Deletion is a flag, never a removal. */
function remove(chars: Char[], id: CharId): Char[] {
  return chars.map((char) =>
    compareIds(char.id, id) === 0 ? { ...char, deleted: true } : char,
  );
}

/** The visible document is the non-tombstoned characters in order. */
function render(chars: Char[]): string {
  return chars.filter((char) => !char.deleted).map((char) => char.value).join('');
}

Three properties follow from this design, and they are why CRDTs won for new systems:

Merge is commutative, associative and idempotent. Order of arrival does not matter, duplicates are harmless, and the same update can be applied twice with no effect. That is what makes replay after reconnect trivial rather than a special case.

No server decision is required. The server can be a dumb relay that broadcasts and stores opaque bytes. Compare that with an OT server maintaining per-document history and transforming every operation against it.

Deletions become tombstones, because a character other operations may anchor to cannot simply vanish. That is the source of the main cost: tombstones and per-character metadata accumulate, so a document edited for years carries a long history of characters nobody can see. Mature libraries mitigate this with run-length encoding of contiguous characters, garbage collection once all replicas have acknowledged a state, and compact binary encodings - Yjs stores updates efficiently enough that the overhead is unremarkable in practice.

Choosing

OTCRDT
Central authorityRequiredNot required
Server roleTransform and order every operationRelay and persist opaque updates
Offline for a long periodServer must transform the whole backlogMerges naturally in any order
Metadata overheadLow - integer positionsPer-character ids and tombstones
Complexity locationTransformation matrix, grows with the schemaData structure and its integration rule
Peer-to-peerImpracticalNatural
Mature librariesShareDB, ot.jsYjs, Automerge, Loro

For a new system, CRDT is the default, and the strongest reason is the server: a relay is dramatically easier to build, scale and reason about than a transformation authority. OT remains reasonable when you already operate one and when documents are always online - and Google Docs, the most famous collaborative editor, is OT-based, which is worth acknowledging rather than dismissing.

Local-First Architecture

The architectural consequence of either choice is the same: the local replica is what the user edits, and the network is a synchronisation detail.

Diagram
100%
flowchart TB subgraph LOCAL["Client - a full replica, not a view"] KEY["keystroke / command"] DOC[("CRDT document state<br/>(the source of truth for this user)")] LOG[("durable local log<br/>IndexedDB - survives reload")] VIEW["editor view<br/>renders from DOC"] PRES["ephemeral presence<br/>(never merged into DOC)"] end subgraph NET["Sync"] OUT["outbound update queue"] CONN["WebSocket<br/>(reconnect + backoff)"] IN["inbound updates"] end subgraph SERVER["Server - relay and store"] RELAY["broadcast to other clients<br/>in this document room"] STORE[("append-only update log<br/>+ periodic snapshots")] AWARE["presence relay<br/>(not persisted)"] end KEY -->|"1. apply locally, immediately"| DOC DOC -->|"2. render - no network in this path"| VIEW DOC -->|"3. append update"| LOG LOG -->|"4. enqueue"| OUT --> CONN --> RELAY --> STORE RELAY --> IN --> DOC LOG -->|"replay unsent on reconnect"| OUT PRES <--> CONN CONN <--> AWARE RELOAD["page reload"] -.->|"hydrate from LOG,<br/>then catch up from STORE"| DOC style LOCAL fill:#1e3f2d,stroke:#22c55e style NET fill:#3f2d1e,stroke:#f59e0b style SERVER fill:#1e3a5f,stroke:#3b82f6 style PRES fill:#1e293b,stroke:#475569
visualized byIOCombats
class CollaborativeDocument {
  private doc = new CrdtDocument();
  private outbound: Uint8Array[] = [];

  constructor(
    private storage: LocalUpdateLog,
    private transport: SyncTransport,
  ) {
    // Remote updates take the same path as local ones - merge, then render.
    this.transport.onUpdate((update) => this.applyRemote(update));
    this.transport.onReconnect(() => void this.resync());
  }

  /** The typing path. Nothing here awaits the network. */
  applyLocal(change: DocumentChange) {
    const update = this.doc.apply(change);

    this.notifyView();                 // rendered before anything is sent
    void this.storage.append(update);   // durable, so a reload loses nothing
    this.outbound.push(update);
    this.transport.send(update);        // best effort; the queue is the backstop
  }

  private applyRemote(update: Uint8Array) {
    // Idempotent and commutative, so no ordering or deduplication needed.
    this.doc.merge(update);
    this.notifyView();
  }

  private async resync() {
    // Send everything the server may not have. Re-sending an update the server
    // already has is harmless, which is what makes this safe to do bluntly.
    const unsent = await this.storage.readAll();
    for (const update of unsent) this.transport.send(update);

    // Then ask for anything we missed, identified by our state vector rather
    // than by a timestamp or a sequence number.
    const missing = await this.transport.requestMissing(this.doc.stateVector());
    for (const update of missing) this.doc.merge(update);

    this.notifyView();
  }
}

Three consequences worth stating explicitly:

There is no loading state for editing. The document is local, so typing works before any connection is established and continues if it drops. Connection status becomes an indicator, not a gate.

A reload loses nothing. The local log is hydrated first, then the client catches up from the server.

Resync is blunt and safe. Because merge is idempotent, "send everything you might not have, then ask for everything I might be missing" is a correct strategy. The state vector - the highest counter seen per site - is what makes the second half efficient: it describes exactly what a replica has without enumerating it.

This is the queue-and-replay pattern from Offline and PWA Architecture taken to its conclusion, and it inverts the model in State Management Architecture: the client is not a cache of server state, it is a peer replica.

Presence

Presence is ephemeral and must never enter the document. It is worthless a second after it is sent, and a cursor position saved into content is a bug.

type PresenceState = {
  userId: string;
  name: string;
  colour: string;
  /** Positions as stable CRDT ids, NOT integer offsets. */
  selection: { anchor: CharId; head: CharId } | null;
  updatedAt: number;
};

const PRESENCE_THROTTLE_MS = 100;
const PRESENCE_TIMEOUT_MS = 15_000;

class PresenceChannel {
  private peers = new Map<string, PresenceState>();

  constructor(private transport: SyncTransport, private self: PresenceState) {
    // Throttled: cursor movement can fire far more often than anyone can see.
    this.publish = throttle(this.publish.bind(this), PRESENCE_THROTTLE_MS);

    this.transport.onPresence((state) => {
      this.peers.set(state.userId, state);
      this.notify();
    });

    // A client that closes without a goodbye must not leave a ghost caret.
    setInterval(() => this.expireStale(), 5_000);
  }

  publish(selection: PresenceState['selection']) {
    this.self = { ...this.self, selection, updatedAt: Date.now() };
    this.transport.sendPresence(this.self);
  }

  private expireStale() {
    const cutoff = Date.now() - PRESENCE_TIMEOUT_MS;
    let changed = false;

    for (const [userId, state] of this.peers) {
      if (state.updatedAt < cutoff) {
        this.peers.delete(userId);
        changed = true;
      }
    }

    if (changed) this.notify();
  }
}

Three requirements:

Positions must be stable identifiers, not integer offsets. If Alice's cursor is stored as "index 42" and Bob inserts a paragraph above it, Alice's caret silently points at different text on every other client. Expressing it as a character id means it moves with the content, which is the same reason the document model uses ids in the first place.

Presence expires on a timeout. A tab closed by a crash sends no farewell, and a permanent ghost caret is both confusing and a privacy oddity.

Rendering is an overlay. Remote carets and selection highlights are absolutely positioned above the text, computed from character ids to screen coordinates - never inserted into the document, which would make them part of the content and part of every other client's merge.

Presence should also be throttled harder than it feels necessary. Cursor movement can fire hundreds of times a second, and a document with ten collaborators broadcasting unthrottled is a self-inflicted denial of service.

History and Versioning

Two strategies, and production systems use both.

Operation log replay stores every update. Any past state is reachable by replaying to a point, giving perfect granularity and true attribution of who changed what. It grows without bound and replay gets slower with document age.

Snapshots store the full document state periodically. Loading is fast and size is bounded, but the detail between snapshots is lost.

const SNAPSHOT_EVERY_UPDATES = 500;

async function persistUpdate(documentId: string, update: Uint8Array) {
  const { updateCount } = await db.document.update({
    where: { id: documentId },
    data: { updates: { push: update }, updateCount: { increment: 1 } },
    select: { updateCount: true },
  });

  if (updateCount % SNAPSHOT_EVERY_UPDATES !== 0) return;

  // A snapshot makes cold loads fast: one read instead of thousands of merges.
  const merged = await mergeAllUpdates(documentId);
  await db.documentSnapshot.create({
    data: { documentId, state: merged, atUpdateCount: updateCount },
  });

  // Only compact updates the snapshot supersedes, and only those older than the
  // retention window, so recent history stays available for undo and audit.
  await compactUpdatesBefore(documentId, updateCount, RETENTION_WINDOW_MS);
}

The hybrid gives fast loads from the latest snapshot plus fine-grained recent history, with older detail compacted away. Two details matter: named versions ("before the review") should be explicit snapshots that are never compacted, and restoring a version must be a new change rather than a rewind - applying the inverse as fresh updates - because rewinding state that other replicas have already merged is exactly the divergence this architecture exists to prevent.

Undo needs a note. In a collaborative document, undo must be per user: Alice pressing Cmd-Z should undo Alice's last change, not Bob's, even if Bob's was more recent. That means the history stack filters by origin, and the undone change is expressed as a new inverse update rather than as a removal from history. Getting this wrong - a global undo stack - produces the most alarming possible bug, where one user's undo deletes another user's work.

Common Interview Follow-Up Questions

"A user has been editing offline for a week. Two hundred changes each side. What happens on reconnect?" With a CRDT, mechanically it just works: the client sends its accumulated updates, requests what it is missing by state vector, and both sides converge. The interesting part is that convergence is not the same as a good outcome. If both users rewrote the same paragraph, the merged text contains both versions interleaved - technically correct, semantically nonsense. So a long divergence needs product handling on top of the merge: detect that the reconnecting client's base is far behind, and offer to merge, to fork into a copy for manual reconciliation, or to review changes side by side. Silent merging is right for minutes of divergence and wrong for a week of it, and knowing where that line sits is a design decision rather than a technical one.

"How does this scale to 100 concurrent editors on one document?" Poorly, without work, and the bottlenecks are not where people expect. Update fan-out is quadratic in editors if every change is broadcast to everyone individually, so the server should batch updates over a short window and send merged deltas rather than one message per keystroke. Presence is worse than document data at this scale - a hundred cursors is a hundred throttled streams and a hundred overlay elements - so it needs harder throttling, and past a threshold you show "12 people editing" instead of individual carets. Rendering also becomes the limit: a hundred remote carets and highlights is enough DOM churn to affect typing latency. In practice most products cap simultaneous editors and degrade extras to viewers, which is an honest answer.

"Where does rich text formatting fit into a CRDT?" Marks are the awkward case, because a bold span is a property of a range and ranges are exactly what concurrent edits invalidate. The standard approach represents formatting as its own CRDT layer keyed by character ids rather than offsets, so bold-from-here-to-there survives insertions in the middle. Concurrent conflicting formats - one user bolds, another unbolds the same range - need a deterministic resolution rule, usually a last-writer-wins register per character per mark. Block-level structure such as list nesting is harder still and is where CRDT text libraries differ most. This is the strongest practical argument for using Yjs or Automerge with a supported editor binding rather than implementing your own: the plain-text CRDT above is a weekend, and correct rich text on top of it is not. The document model this layers onto is described in Rich Text Editor.

"How do you test convergence?" Property-based testing, because example-based tests cannot cover the interleavings that matter. Generate random operation sequences across N simulated replicas, deliver them in random orders with duplicates and delays, and assert that every replica ends byte-identical - that single property catches almost every real convergence bug. Then add targeted tests for the cases that are known to be hard: concurrent insert at the same anchor, delete of a range another client is inserting into, an update applied twice, and a replica that receives updates in reverse order. Fuzzing with a seeded generator makes failures reproducible, which matters because a convergence bug found without a seed is nearly impossible to shrink by hand. The layering rationale is in Testing Strategy.

"What breaks if the WebSocket connection is unreliable?" Much less than in a non-local-first design, which is the point. Typing continues against the local replica, updates accumulate in the durable log, and resync on reconnect is idempotent so nothing needs to be tracked as sent-or-not. What does need care is the connection layer itself - heartbeats to detect a silently dead socket, exponential backoff with jitter so a server restart does not trigger a stampede, and a visible connection indicator so users know their collaborators are not seeing changes yet. Those mechanisms are exactly the ones built in Real-Time Feed, reused rather than reinvented, and the transport reasoning is in Networking and Data Fetching.

Tradeoffs Table

OptionProsConsWhen to Use
Last-write-wins on the whole documentTrivial; no merge logic at allSilently destroys concurrent workSingle-editor documents with an explicit lock
Operational TransformationLow metadata overhead, integer positions, proven at scaleNeeds a central transforming server; transformation matrix grows with the schema; long offline periods are painfulYou already operate an OT server and documents are always online
CRDTNo central authority, offline merges naturally, peer-to-peer capable, idempotent updatesPer-character metadata and tombstones; harder to reason about; rich text is genuinely hardDefault for new collaborative systems
Server-authoritative editingSimple mental model, one source of truthEvery keystroke waits on the network; unusable offlineLow-frequency structured editing, not prose
Local-first replicaInstant typing, works offline, reload-safe, blunt resync is correctClient holds a full replica; local storage and merge become critical codeAny real-time collaborative editor
Operation log onlyPerfect granularity and attribution, any past state reachableUnbounded growth; cold loads get slower foreverShort-lived documents, or where full audit is a requirement
Snapshots plus recent logFast cold loads, bounded size, fine-grained recent historyDetail between compacted snapshots is lostDefault for long-lived documents

Where This Applies

Collaborative editing inverts the central assumption of State Management Architecture: the client stops being a cache of server-owned state and becomes a replica that can accept writes independently, which is why the merge function rather than the fetch layer becomes the most important code in the system. It is also the most complete expression of the queue-and-replay model in Offline and PWA Architecture, since offline editing is not a degraded mode but the same path the online case takes. And the transport - persistent connection, heartbeats, backoff, state-vector-based catch-up - is the machinery described in Networking and Data Fetching.

Within this track it builds directly on Rich Text Editor, which supplies the document model and transaction layer that make collaboration expressible at all. Its connection handling is the same as Real-Time Feed, its durable local log is the pattern used in File Upload System, and long documents eventually need the block windowing described in Virtualized List.

Advertisement

Frequently Asked Questions

What is Operational Transformation and how does it resolve a conflicting edit?

Operational Transformation represents every change as an operation with a position, such as insert this character at index five or delete three characters from index two, and resolves concurrency by rewriting one operation against another so that applying them in either order produces the same result. If two people both insert a character at index five of the same line, the second operation to be processed has its position shifted by the length of the first, so one insertion ends up at five and the other at six rather than both claiming the same slot. The transformation function has to be defined for every pair of operation types and must satisfy a mathematical property called transformation property one, which is what guarantees convergence. In practice OT needs a central server to impose a single ordering and to transform each incoming operation against everything it has already accepted, because two clients transforming independently against different histories can diverge.

What is a CRDT and how does it resolve the same conflicting edit?

A CRDT is a data structure whose merge operation is designed so that any two replicas that have seen the same set of changes end up identical, regardless of the order in which they received them. For text, the usual approach abandons integer positions entirely and gives every inserted character a globally unique identifier plus a stable position relative to its neighbours, so an insertion says place this character after the character with this id rather than at index five. Two concurrent insertions after the same neighbour then both exist, and a deterministic tiebreak on their identifiers decides which comes first - every replica applies the same rule and reaches the same order without consulting a server. Deletions are handled by marking a character as removed rather than deleting it, because a position that other operations may reference cannot simply vanish.

Why have CRDTs become more common than OT for new systems?

Three reasons. They need no central transformation authority, so the server can be a dumb relay that broadcasts and stores opaque updates, which is dramatically simpler to build and to operate than a server that must maintain per-document history and transform every operation against it. They handle offline editing naturally, because a client that has been disconnected for a week can merge its accumulated changes with the world's in any order and still converge, whereas OT requires the server to transform that entire backlog against everything that happened meanwhile. And they support peer-to-peer and multi-server topologies, since convergence is a property of the data structure rather than of a single ordering authority. The costs are real - metadata overhead per character, tombstones that accumulate, and a genuinely harder mental model - but mature libraries now absorb most of that.

What does local-first architecture mean in practice for an editor?

It means the local replica is the thing the user is editing, and the network is a synchronisation detail rather than a prerequisite. Every keystroke applies to local state immediately and renders without waiting for anything, the change is appended to a durable local log so it survives a reload, and only then is it broadcast. Remote changes arrive and merge into the same local replica. The consequence is that there is no loading state for editing, no spinner between typing and seeing the character, and no difference in behaviour between online and offline beyond a connection indicator. It also means the client is no longer a view over server state but a full replica, which changes what has to be stored locally and makes the merge function the most important piece of code in the system.

How do you show other people's cursors, and why is presence handled differently from document data?

Presence is broadcast as ephemeral state rather than merged into the document, because it is worthless a second after it is sent and must never become part of the saved content. Each client publishes its user identity, a colour, and its selection expressed in the same position space the document uses, on a throttled interval and on every selection change. Other clients render those positions as coloured carets and highlights in an overlay layer that sits above the text without participating in it. Two properties matter. Positions must be expressed in stable identifiers rather than integer offsets, or a remote edit above someone's cursor silently moves it to the wrong place. And presence needs an expiry, because a client that closes without saying goodbye should have its cursor disappear on a timeout rather than leaving a ghost caret in the document forever.