Excalidraw Onboarding LabChapter VI — Collab: broadcasting local edits and reconciling remote ones0/6Contents
Chapter VI

Collab: broadcasting local edits and reconciling remote ones

Collaboration in excalidraw-app/collab/Collab.tsx is the app-shell layer that turns local Scene edits into socket broadcasts and turns incoming remote element arrays back into local Scene state, all without corrupting undo history. It does this entirely through the same public excalidrawAPI surface (getSceneElementsIncludingDeleted, updateScene) any embedder would use, resolving conflicts with per-element version/versionNonce comparisons and tagging remote writes with CaptureUpdateAction.NEVER.


One edit's round trip: local Scene change out, remote payload back in
Local Scene edit reaches Appexcalidraw-app/App.tsx
collabAPI.syncElementsexcalidraw-app/collab/Collab.tsx
broadcastElementsexcalidraw-app/collab/Collab.tsx
Portal / socket roomexcalidraw-app/collab/Portal.tsx
_reconcileElementsexcalidraw-app/collab/Collab.tsx
handleRemoteSceneUpdateexcalidraw-app/collab/Collab.tsx
updateScene(CaptureUpdateAction.NEVER)excalidraw-app/collab/Collab.tsx
Figure I · Click a node to see what it does, where it lives in the code, and its labeled connections — the annotation opens beside it.
walkthrough

syncElements / broadcastElements: how a local edit leaves the Scene

excalidraw-app/collab/Collab.tsx · lines 943957
lines 943–947

Before sending anything, broadcastElements compares getSceneVersion(elements) - a single number summarizing the whole array - against the version this client last broadcast or received. If it hasn't grown, nothing is sent. This guard is what stops a remote update, once written back into the Scene, from being mistaken for a new local edit and echoed straight back out.

lines 948–950

Once the version has actually moved forward, the array goes to portal.broadcastScene tagged WS_SUBTYPES.UPDATE, this client records the new version as its own last-seen value, and it queues a throttled full-scene resync (queueBroadcastAllElements) as a safety net in case an incremental message gets dropped.

lines 954–957

syncElements is the entry point the app shell actually calls (via collabAPI.syncElements) on every local Scene change: it broadcasts over the socket and, on the same throttle cadence, queues a save of the same elements to Firebase so late joiners and reconnects have somewhere to catch up from.

  broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {    if (      getSceneVersion(elements) >      this.getLastBroadcastedOrReceivedSceneVersion()    ) {      this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false);      this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);      this.queueBroadcastAllElements();    }  };  syncElements = (elements: readonly OrderedExcalidrawElement[]) => {    this.broadcastElements(elements);    this.queueSaveToFirebase();  };
step 1 of 3
walkthrough

_reconcileElements: merging a remote array with the local Scene

excalidraw-app/collab/Collab.tsx · lines 754786
lines 754–759

_reconcileElements takes only the remote array as its argument; existingElements is pulled fresh from the Scene via getSceneElementsIncludingDeleted - the same authoritative array from earlier modules, but here including tombstoned (deleted) elements, since a remote delete has to be compared against a local one too.

lines 761–764

restoreElements normalizes the raw remote payload against the existing elements before merging. The comment explains why this can't happen after reconciliation instead: it would regenerate transient state such as appState.newElement and break it.

lines 766–770

reconcileElements is the library merge function: for every remote element it decides, per element, whether to keep the local copy or accept the remote one, then folds in whatever local elements the remote array never mentioned.

lines 772–775

bumpElementVersions then walks the merged array and, wherever the pre-merge local copy was newer or had a different versionNonce, bumps that element's version again relative to the local one. This keeps versions monotonically increasing across the merge, so this client's own next broadcastElements call still sees forward progress instead of getting stuck thinking nothing changed.

lines 777–783

Before the reconciled elements ever reach the Scene, this line records their scene version as the last broadcast-or-received one. Order matters here: the updateScene call that follows (inside handleRemoteSceneUpdate) fires onChange synchronously, and without this line broadcastElements would see a 'new' version and re-broadcast a scene this client only just received.

lines 785–786

The reconciled, version-bumped array is returned to whichever caller invoked it - the client-broadcast socket handler or saveCollabRoomToFirebase - which passes it straight to handleRemoteSceneUpdate.

  private _reconcileElements = (    remoteElements: readonly RemoteExcalidrawElement[],  ): ReconciledExcalidrawElement[] => {    const appState = this.excalidrawAPI.getAppState();    const existingElements = this.getSceneElementsIncludingDeleted();    // NOTE ideally we restore _after_ reconciliation but we can't do that    // as we'd regenerate even elements such as appState.newElement which would    // break the state    remoteElements = restoreElements(remoteElements, existingElements);    let reconciledElements = reconcileElements(      existingElements,      remoteElements,      appState,    );    reconciledElements = bumpElementVersions(      reconciledElements,      existingElements,    );    // Avoid broadcasting to the rest of the collaborators the scene    // we just received!    // Note: this needs to be set before updating the scene as it    // synchronously calls render.    this.setLastBroadcastedOrReceivedSceneVersion(      getSceneVersion(reconciledElements),    );    return reconciledElements;  };
step 1 of 6
export const shouldDiscardRemoteElement = (  localAppState: AppState,  local: OrderedExcalidrawElement | undefined,  remote: RemoteExcalidrawElement,): boolean => {  if (    local &&    // local element is being edited    (local.id === localAppState.editingTextElement?.id ||      local.id === localAppState.resizingElement?.id ||      local.id === localAppState.newElement?.id ||      // local element is newer      local.version > remote.version ||      // resolve conflicting edits deterministically by taking the one with      // the lowest versionNonce      (local.version === remote.version &&        local.versionNonce <= remote.versionNonce))  ) {    return true;  }  return false;};
packages/excalidraw/data/reconcile.ts · lines 2344
Figure II · This is the per-element rule reconcileElements applies inside the merge you just walked through. version behaves like a revision counter every edit increments, so a strictly higher local version simply wins. But two collaborators can bump the same element to the same version at nearly the same instant - version alone can't break that tie. versionNonce is a random number reserved exactly for that: when versions are equal, the side with the lower versionNonce is kept as local. It's arbitrary by design - neither client has to know anything about the other - but it's deterministic, so every client that sees both copies resolves the tie the same way.
  private handleRemoteSceneUpdate = (    elements: ReconciledExcalidrawElement[],  ) => {    this.excalidrawAPI.updateScene({      elements,      captureUpdate: CaptureUpdateAction.NEVER,    });    this.loadImageFiles();  };
excalidraw-app/collab/Collab.tsx · lines 803812
Figure III · Every reconciled remote batch is written back into the Scene through the exact same updateScene call any other part of the app uses - but with captureUpdate: CaptureUpdateAction.NEVER. You've already seen CaptureUpdateAction decide whether a scene change lands on the undo/redo stack; NEVER makes this particular write invisible to the Store, so pressing Ctrl+Z never undoes a teammate's edit as though you had made it yourself.
checkpoint

Check your understanding

Answered in place — nothing is graded, everything is explained. 0 / 3 passed

Before broadcastElements sends a scene update over the socket, what does it check?

Why does handleRemoteSceneUpdate call updateScene with captureUpdate: CaptureUpdateAction.NEVER?

A local element and its remote counterpart both have version 7 but different versionNonce values. Which one does reconciliation keep?