Whiteboard: Reduce Broadcast Overhead & Stabilize Implementation

Overview & Context

Follow-up for the merged Excalidraw whiteboard implementation. Fixes throttle/debounce wiring so rate-limiting actually works, reduces network message volume, restores scene continuity across layout switches, and closes small gaps left from the initial PR.

Technical Design & Interfaces

Every throttle(...) call in WhiteboardView.tsx is placed inline in the render body. Each render produces a fresh instance whose internal timing state starts at zero, so the rate-limiting probably never actually fires. The fix should make each instance survive across renders

Additionally:

  • handleChange currently dispatches broadcastScene synchronously on every onChange frame - introduce a debounce here
  • queueBroadcastAllElements should switch to { leading: false, trailing: true } + dirty-check so idle scenes don't emit repeated identical snapshots
  • throttledRelayUserViewportBounds is a factory (() => throttle(...)()) that discards the throttle each call - replace with a direct stable reference

Frontend & UI Requirements

Implementation Notes

Fixing the inline throttle / lint-suppression problem

The root cause behind the // eslint-disable-next-line react-hooks/refs suppressions is that throttle(...) is called inline in the render body, producing a fresh instance each render

The goal is to ensure each throttle/debounce instance is created once and survives re-renders. Some approaches to consider:

  • Lazy-initialized refs or state
  • If the throttled callback needs values that change over time, a ref-indirection pattern can avoid stale closures without recreating the throttle

Reducing message volume in handleChange

Currently handleChange fires broadcastScene synchronously on every onChange call. Excalidraw fires onChange on each animation frame during drawing, so a simple dot-click generates 2-3 broadcasts with essentially the same payload.

Ideas:

  • Replace the synchronous broadcastScene call with a debounce (e.g. 50 ms wait, 250 ms maxWait) so rapid same-frame changes coalesce. The maxWait cap ensures continuous strokes still stream out at a bounded rate.
  • Alternatively, keep a throttle but add a dirty-check: skip dispatch if the set of changed elements since the last broadcast is empty.

Viewport bounds (throttledRelayUserViewportBounds)

The current () => throttle(relayVisibleSceneBounds, CURSOR_SYNC_TIMEOUT)() creates a new throttle per scroll event, then immediately invokes and discards it - equivalent to no throttling. The fix is to construct the throttle once and pass it directly to api.onScrollChange(...).

Layout-switch state loss

Cinema.tsx conditionally renders <WhiteboardView /> - switching to Speaker/Grid unmounts the component entirely. On remount the scene is rebuilt from selectWhiteboardElements (last scene_stored, up to 20 s stale) and all deltas received while hidden are lost.

Idea:

  • Render all layouts into a wrapper styled with display: none / display: flex so the component instance survives.

Periodic full broadcast dirty-check

queueBroadcastAllElements currently fires every 20 s regardless of whether the scene changed. Adding a lastFullSyncedSceneVersion ref and skipping the dispatch when unchanged avoids wasting bandwidth on idle whiteboards (common in meetings where the whiteboard was used once and left open).

Implementation Tasks

  • Stabilize throttle instances for onPointerUpdate, queueStoreSceneToBackend, queueBroadcastAllElements; remove // eslint-disable-next-line react-hooks/refs suppressions
  • Fix throttledRelayUserViewportBounds to be a stable single throttle registered as the scroll listener; remove the wrapping closure
  • Reduce delta broadcast volume from handleChange (debounce or dirty-check approach) - verify with dot-click test (expect 1 broadcast, not 2-3)
  • Add dirty-check to queueBroadcastAllElements so idle whiteboards don't ship repeated identical snapshots
  • Keep WhiteboardView mounted across layout switches in Cinema.tsx
  • Add handler for Stopped whiteboard event in whiteboard.ts handler + corresponding reducer
  • Remove unused isFollowingRef (set but never read) or use it if it has a purpose

Definition of Done

  • no react-hooks/refs suppressions remain in WhiteboardView.tsx.
  • Manual verification successful on Chrome and Firefox:
    • Drawing a single dot triggers ≤ 1 delta broadcast
    • Continuous freehand drawing is rate-limited but visibly smooth on a peer client.
    • Switching Whiteboard → Speaker → Whiteboard preserves scene state and remote cursors without blackout
  • The whiteboard opens directly for everyone as soon as they start it
  • No regressions to cursor sync, follow/unfollow, edit restrictions
  • Unit tests for debounce/throttle wiring

The following discussion from !3395 should be addressed:

  • @m.fuss started a discussion: (+2 comments)

    I think we're still sending too many messages. I tested this by simply clicking the mouse to draw a dot and found that we're sending two or three broadcast events containing essentially the same information.

    As this also affects the entire drawing process - it seems that onChange is triggered on every animation frame, producing a lot more messages than are (probably) needed

    it's worth checking whether using 'debounce' here would improve performance by reducing the number of messages sent

Edited by Maximilian Fuß