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:
handleChangecurrently dispatchesbroadcastScenesynchronously on everyonChangeframe - introduce a debounce herequeueBroadcastAllElementsshould switch to{ leading: false, trailing: true }+ dirty-check so idle scenes don't emit repeated identical snapshotsthrottledRelayUserViewportBoundsis 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
broadcastScenecall with a debounce (e.g. 50 ms wait, 250 msmaxWait) so rapid same-frame changes coalesce. ThemaxWaitcap 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: flexso 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/refssuppressions - Fix
throttledRelayUserViewportBoundsto 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
queueBroadcastAllElementsso idle whiteboards don't ship repeated identical snapshots - Keep
WhiteboardViewmounted across layout switches in Cinema.tsx - Add handler for
Stoppedwhiteboard 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/refssuppressions 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
onChangeis triggered on every animation frame, producing a lot more messages than are (probably) neededit's worth checking whether using 'debounce' here would improve performance by reducing the number of messages sent