ArchitectureJanuary 20, 2026Architecture Design / Part 1

Realtime collaboration and undo/redo architecture

How I would structure the frontend for realtime collaboration, local undo and redo, and multiplayer-safe history using WebSockets, command-style operations, and memento-like snapshots.

frontendarchitecturecollaborationwebsocketundo-redodesign-patterns
Back to blog

Most realtime collaboration posts stop too early. They explain how to open a socket, broadcast events, and show another user's cursor. That part is easy. The hard part starts when local edits, remote edits, undo, redo, retries, optimistic state, and partial failure all hit the same object graph.

That is the piece I care about. Not the transport by itself, but the shape of the frontend architecture around it.

Architecture diagram showing UI, local state, history manager, collaboration client, WebSocket transport, and backend acknowledgement flow

The shape that keeps the system readable: local state, history, collaboration routing, and transport all have separate jobs.

The hard part is not the socket

A WebSocket connection does not give you collaboration. It gives you delivery. Collaboration starts when the frontend decides what an operation means, when it becomes durable, how it is replayed, and whether it belongs in local history.

That is where sloppy systems start to wobble. A few questions expose it quickly:

  • Does undo reverse my action, or does it try to reverse the whole document?
  • If another user edits the same object, does my redo still make sense?
  • Are cursor updates treated the same as persisted changes?
  • Can I replay a local history entry if the object was deleted remotely?
  • Is history recording raw state churn or user intent?

You do not solve those problems in the transport. You solve them by giving operations, snapshots, and history clear roles.

The split that keeps the frontend sane

The most useful idea in the reference implementation is not a specific API call. It is the separation between three concerns:

  • the collaboration client
  • the transport
  • the history and snapshot layer

The collaboration client coordinates session-level behavior:

collaboration-client.js
class CollaborationClient {
  constructor(redaction, options = {}) {
    this.redaction = redaction;
    this.transportFactory =
      options.transportFactory || CollaborationClient.defaultTransportFactory;
    this.transport = null;
    this.presenceView = null;
    this.cursorView = null;
    this.documentId = null;
    this.clientId = CollaborationUtils.getClientId();
  }

  async connect(documentId) {
    if (!documentId) return;
    await this.disconnect();
    this.documentId = documentId;

    this.transport = this.transportFactory({
      documentId,
      clientId: this.clientId,
      authUrl: authUrl.toString(),
      channelPrefix: "document",
    });

    await this.transport.connect({
      onOperation: (event) => this.handleOperation(event),
      onCursorUpdate: (event) =>
        this.cursorView?.handleRemoteUpdate(event),
      onCursorLeave: (event) =>
        this.cursorView?.handleRemoteLeave(event),
      onPresenceEnter: (member) =>
        this.presenceView?.updateMember(member),
      onPresenceLeave: (member) =>
        this.presenceView?.removeMember(member),
    });
  }

  handleOperation(event) {
    if (!event || event.sourceClientId === this.clientId) return;
    this.redaction?.applyRemoteAnnotationOperation?.(event);
  }
}

I would describe this as the session coordinator for collaboration. It should not know how the domain object is stored internally. It should not know how history is recorded either. Its job is narrower:

  • connect to the right shared session
  • normalize transport events
  • ignore self-echoed messages
  • forward remote operations into the domain layer
  • keep ephemeral signals like presence and cursor movement out of durable history

That last point matters more than it gets credit for. Cursor movement is collaboration, but it is not document state. Presence is collaboration, but it is not undoable. Mixing those concepts is how systems get messy.

Why the transport should stay dumb

The transport should be replaceable. That sounds obvious, but teams often violate it by letting socket event names leak into business logic everywhere.

The reference transport already hints at the right shape:

transport.js
class RealtimeTransport {
  async connect(handlers = {}) {
    this.handlers = handlers;
    this.operationChannel = this.client.channels.get(this.channelName('operations'));
    this.cursorChannel = this.client.channels.get(this.channelName('selection'));
    this.presenceChannel = this.client.channels.get(this.channelName('presence'));

    await this.operationChannel.subscribe((message) => {
      this.handlers.onOperation?.(message.data);
    });
    await this.cursorChannel.subscribe('cursor.update', (message) => {
      this.handlers.onCursorUpdate?.(message.data);
    });
    await this.cursorChannel.subscribe('cursor.leave', (message) => {
      this.handlers.onCursorLeave?.(message.data);
    });
    await this.subscribePresence();
  }
}

I would rewrite this conceptually as our own WebSocket transport, but I would keep the contract:

  • onOperation
  • onCursorUpdate
  • onCursorLeave
  • onPresenceEnter
  • onPresenceLeave

That split is better than a single catch-all stream because the semantics are different:

  • operations are durable and may need replay
  • presence is session state
  • cursor movement is ephemeral and should disappear quietly

The frontend should consume domain events, not raw socket ceremony.

Diagram separating durable operations from ephemeral cursor and presence events

Durable operations deserve a different path from cursors and presence. Treating them as one event stream makes the frontend harder to reason about.

Undo should reverse the current user's intent, not rewind the entire shared document.

Undo and redo should be local, not global

The central mistake in multiplayer editors is treating undo like a time machine. It is not. It is a local reversal of user intent inside a shared timeline.

If I create an object and then undo, I want to reverse my create. I do not want to rewind everyone else's edits that happened after mine. If another user changed a different object while I was working, my undo should leave that work alone.

That means the history stack should record my commands, not a stream of full-document snapshots.

The reference annotation-history.js is useful because it already behaves like a command stack with before/after payloads:

annotation-history.js
class AnnotationHistoryManager {
  constructor(redaction, options = {}) {
    this.redaction = redaction;
    this.undoStack = [];
    this.redoStack = [];
    this.maxEntries = options.maxEntries || 100;
    this.isApplying = false;
  }

  recordUpdate(beforeSnapshot, box) {
    const afterSnapshot = this.snapshot(box);
    if (!beforeSnapshot
      || !afterSnapshot
      || this.isApplying
      || AnnotationHistoryManager.snapshotsEqual(beforeSnapshot, afterSnapshot)) {
      return;
    }

    this.push({
      type: 'update',
      before: beforeSnapshot,
      after: afterSnapshot
    });
  }

  recordCreate(box) {
    const snapshot = this.snapshot(box);
    if (!snapshot) return;

    this.push({
      type: 'create',
      after: snapshot
    });
  }

  recordDelete(box) {
    const snapshot = this.snapshot(box);
    if (!snapshot) return;

    this.push({
      type: 'delete',
      before: snapshot
    });
  }
}

That shape is important. Each history entry has intent:

  • create
  • delete
  • update

And for the reversible cases, it has enough state to move backward or forward safely:

  • before
  • after

This is where the memento pattern becomes useful.

Where the memento pattern actually helps

People mention the memento pattern a lot in undo/redo discussions, but they often leave it at a textbook definition. In a collaborative frontend, the practical version is simple:

  • the live object is the current mutable state
  • the memento is a snapshot of the object's durable fields at a point in time
  • the history manager stores and replays those snapshots in a controlled way

The snapshot method in the reference is exactly that idea:

annotation-history.js
snapshot(boxOrAnnotation) {
  if (!boxOrAnnotation) return null;

  const annotation = boxOrAnnotation.pdfAnnotation
    ? boxOrAnnotation
    : this.redaction.serializeAnnotation(boxOrAnnotation, {
        includeId: !!boxOrAnnotation.id
      });

  if (!annotation?.id) return null;

  return JSON.parse(JSON.stringify(annotation));
}

That deep-cloned payload is not "the object." It is a memento. A stable record of the object's restorable state.

The reason I like a memento-style snapshot here is that it gives undo and redo a durable payload that is independent of current object references. That matters in collaborative systems because the live object may already have been mutated again by the time you hit undo. If your history entry only stores a pointer to the current object, your past disappears.

The memento pattern also helps decouple UI state churn from history state. The user can drag, hover, focus, and resize all day long. History only needs stable restoration payloads for the moments that count.

This is command plus memento, not memento alone

Pure snapshot rewind is usually too blunt for collaborative editors. Pure command replay is often too brittle. The reference lands in a good middle ground:

  • command-like operation types define intent
  • memento-like snapshots carry restorable state

That combination is much more practical than either extreme.

You can see it in the apply logic:

annotation-history.js
async apply(operation, direction) {
  if (operation.type === 'create') {
    if (direction === 'undo') {
      if (this.hasSnapshot(operation.after)) {
        await this.deleteSnapshot(operation.after);
      }
    } else if (!this.hasSnapshot(operation.after)) {
      await this.restoreSnapshot(operation.after);
    }
    return;
  }

  if (operation.type === 'delete') {
    if (direction === 'undo') {
      if (!this.hasSnapshot(operation.before)) {
        await this.restoreSnapshot(operation.before);
      }
    } else if (this.hasSnapshot(operation.before)) {
      await this.deleteSnapshot(operation.before);
    }
    return;
  }

  if (operation.type === 'update') {
    const snapshot = direction === 'undo' ? operation.before : operation.after;
    if (this.hasSnapshot(snapshot)) {
      await this.restoreSnapshot(snapshot);
    }
  }
}

That is not full-document rewind. It is domain-level inversion:

  • undo create -> delete the created thing
  • undo delete -> restore the deleted thing
  • undo update -> restore the previous snapshot

That is a much better match for multiplayer editing because it reverses the local command without pretending the rest of the world stood still.

State restoration needs its own adapter

The memento pattern is much easier to work with if restoration is explicit. The reference uses annotation-state.js for exactly that.

The adapter restores or upserts domain objects from snapshots:

annotation-state.js
class AnnotationStateAdapter {
  constructor(redaction) {
    this.redaction = redaction;
  }

  upsertFromSnapshot(annotation) {
    const pageNumber = Number(annotation?.pdfAnnotation?.pageNumber);
    const annotationId = Number(annotation?.id);
    if (!Number.isInteger(pageNumber) || !Number.isInteger(annotationId)) {
      return null;
    }

    let box = this.redaction.findAnnotationById(pageNumber, annotationId);
    if (box) {
      box.deleted = false;
      this.redaction.applyServerAnnotation(box, annotation);
      this.redaction.updateCachedAnnotationsForPage(pageNumber);
      return box;
    }

    box = this.snapshotToBox(annotation);
    this.redaction.annotations.push(box);
    this.redaction.updateCachedAnnotationsForPage(pageNumber);
    return box;
  }
}

This is an underrated part of the design. History storage and history restoration should not be mixed into random UI handlers. Once restoration has a dedicated adapter, a few things get easier:

  • undo and redo can restore state through one path
  • remote operations can reuse the same rehydration rules
  • tests can verify restoration without needing the whole UI mounted
  • your mementos have a single translation boundary back into live objects

If I were building this from scratch, I would keep that adapter and make it even more explicit.

Group by user intent, not event count

A drag gesture may emit fifty updates. A text input may emit one event per keystroke. A resize may produce a stream of intermediate values that no human thinks of as separate actions.

History should follow user intent, not event frequency.

That means your history manager needs grouping rules:

  • pointer down starts a possible grouped command
  • intermediate movement updates live state but does not push to history
  • pointer up commits one update entry with one before and one after

The current API already points there because recordUpdate(beforeSnapshot, box) separates the start state from the final state. I would lean into that and make grouping a first-class concern.

Timeline showing many UI events grouped into a single undoable command

The UI may emit dozens of intermediate updates. History should still capture one user action.

The awkward cases are the real design test

Single-user undo is easy. Multiplayer undo is where architectural decisions are exposed.

A nice undo stack in a single-user demo proves almost nothing. The real test is what happens after the world changes underneath your history.

Remote delete before local undo

If my undo entry points to an object that another user deleted, what should happen?

There are a few valid choices:

  • restore it from the stored memento
  • reject the undo with a conflict
  • restore as a new identity

The right answer depends on product semantics, but the architecture should support making that choice intentionally.

Remote update between local action and local undo

Suppose I move an object. Another user edits its metadata. Then I undo my move. A full-object snapshot restore may accidentally stomp their metadata change unless the snapshot boundary is scoped carefully.

This is why snapshot design matters. The memento should capture the fields that belong to the command being reversed, or the backend should merge carefully.

Redo after the world diverged

Redo is often trickier than undo because it tries to reapply intent into a changed world. If the target object no longer exists, the redo policy needs to be explicit. The reference already hints at this with guard checks like hasSnapshot(...) before restore or delete.

Optimistic state vs acknowledged state

The reference history manager does not stop at local mutation. It persists through the API:

annotation-history.js
async putSnapshot(annotation) {
  const response = await fetch(endpoint, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      ...this.redaction.collaborationHeaders()
    },
    credentials: 'include',
    body: JSON.stringify(annotation)
  });

  if (!response.ok) {
    const body = await response.text().catch(() => '');
    throw new Error(`Restore annotation failed (${response.status}): ${body}`);
  }
}

That is important. Local history says what we intend to do. Server acknowledgement says what became shared truth. In a multiplayer editor, those are not the same thing.

Presence and cursors should stay out of history

Presence and cursors matter for collaboration, but they should not pollute undo and redo.

That sounds obvious, but it is easy to get wrong if your event model is vague. The collaboration client should route these signals separately:

  • operations into domain state
  • cursors into ephemeral UI
  • presence into session UI

Undo and redo should only care about durable user actions. If moving my mouse adds noise to the history model, the architecture has already slipped.

Interactive concept image for collaboration architecture demos

Architecture rules I would protect

If these rules remain true, the collaboration system stays understandable even as complexity grows.

  • The transport only delivers categorized events.
  • The collaboration client coordinates sessions and routes events.
  • Durable operations enter domain state through explicit handlers.
  • Undo and redo record local intent, not global document snapshots.
  • Mementos store restorable object state independent of live references.
  • Restoration goes through a dedicated adapter, not random UI code.
  • Presence and cursor updates stay out of the history model.
  • Grouping happens around user intent, not raw event volume.

Key takeaways

  • Realtime collaboration is mostly a state-modeling problem, not a socket problem.
  • Undo and redo in multiplayer should be local command reversal.
  • The memento pattern is useful when snapshots are explicit and restorable.
  • Command plus memento is often a better fit than pure command replay or pure snapshot rewind.
  • Presence and cursors belong in collaboration UI, not in durable history.
  • A transport abstraction keeps your frontend architecture cleaner than a socket-first design.

Closing thoughts

The interesting part of collaborative frontend architecture is not the moment a remote event arrives. It is what the frontend does next. Does it turn that event into domain state cleanly? Does local history still make sense after the world changes? Can undo reverse what the user meant, instead of mangling the shared timeline?

That is why I like the patterns in this reference. Underneath the product-specific details, it points toward a durable approach: a transport that stays narrow, a collaboration client that routes intent, a history manager that records commands, and memento-like snapshots that make reversal possible without relying on fragile live references.