Files
MultiRoombaRover/perf/spectate-issues/003-room-camera-snapshot-object-url-churn.md
2026-06-12 01:01:53 -04:00

5.7 KiB

Issue 003: Room Camera And Snapshot Object URL Churn

Summary

Room camera and rover snapshot frames are delivered as binary payloads. The client creates a new Blob, creates a new object URL, revokes the previous URL, and updates React state for every frame. In the 30s /spectate audit, this caused 117 img.src mutations and 11MB of binary socket payloads.

Severity

Medium-high.

This is not the biggest measured CPU source, but it is steady work and very visible on low-end devices because JPEG decoding, Blob allocation, object URL churn, image source updates, and React state updates all happen together.

Upstream Likelihood

Low.

This is mostly /spectate and camera-panel specific. It will not be fixed by the / input, control context, or log work. It may benefit indirectly if broad rerenders are reduced, but the frame handling itself needs targeted changes.

Affected Files

  • webui/src/hooks/useRoomCameraSnapshots.js
  • webui/src/hooks/useRoverSnapshots.js
  • webui/src/components/RoomCameraPanel/index.jsx
  • webui/src/components/RoomCameraFeed/index.jsx
  • server/src/services/roomCameraService/socketGateway.js
  • server/src/services/roverSnapshotService/socketGateway.js

Evidence

Artifact:

perf/results/2026-06-12T04-55-16-888Z-root-runtime/root-runtime-report.json

30s sample:

unlabeled binary socket events: 126
unlabeled binary bytes:         11,048,477
roomCamera:frame events:           117
roverSnapshot:frame events:          7
img src mutations:                 117
blink class mutations:             117

The 8x throttled profile also had these CPU entries:

Blob
decodeString
setAttribute

Those line up with binary socket parsing, Blob/object URL creation, and img.src updates.

Current Code Path

useRoomCameraSnapshots handles each frame:

webui/src/hooks/useRoomCameraSnapshots.js:78
const blob = new Blob([buffer], { type: 'image/jpeg' });

webui/src/hooks/useRoomCameraSnapshots.js:79
const url = URL.createObjectURL(blob);

webui/src/hooks/useRoomCameraSnapshots.js:82
URL.revokeObjectURL(prevUrl);

webui/src/hooks/useRoomCameraSnapshots.js:85
setFeeds(...)

useRoverSnapshots has the same pattern.

RoomCameraFeed writes that object URL into an image:

webui/src/components/RoomCameraFeed/index.jsx:24
<img src={feed.objectUrl} ... />

It also toggles blink state for each frame:

webui/src/components/RoomCameraFeed/index.jsx:12
setBlink((prev) => !prev);

Server-side frame rates:

server/src/services/roomCameraService/socketGateway.js:11
STREAM_INTERVAL_MS = 1000

server/src/services/roverSnapshotService/socketGateway.js:11
STREAM_INTERVAL_MS = 333

Why This Matters

The page is doing memory allocation and DOM/image decode work on every camera frame. At one room camera per second this can be acceptable, but /spectate is already busy. When combined with live WebRTC and telemetry maps, this becomes another steady CPU drain.

The blink dot is also doing one React state update and class mutation per frame. It is small, but it is pure extra work.

Fix Strategies

Option A: Drop Frames When The Client Is Behind

Keep only the latest pending frame per camera. If the previous object URL has not been painted or decoded yet, replace the pending buffer instead of forcing every frame through React.

Option B: Throttle Display Updates On Mobile

Render room camera frames at a lower client-side display rate:

desktop: 1fps as today, or configured
mobile:  0.2-0.5fps for secondary room cameras

The server can still emit at its existing rate for other clients, but this client can choose not to render every frame.

Option C: Lazy Mount Or Collapse Room Cameras On Mobile

On portrait mobile, SecondaryRow can defer RoomCameraPanel until:

  • the user scrolls near it
  • the user opens the panel
  • the page is idle after initial rover feeds have settled

Replace the blink toggle with CSS animation or a timestamp updated less often.

For example:

  • keep status text
  • use a CSS pulse class while status is playing
  • avoid toggling React state on every frame

Option E: Stabilize Room Camera Source Lists

RoomCameraPanel currently passes a newly created array of objects into the snapshot hook:

webui/src/components/RoomCameraPanel/index.jsx:37
useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })))

The hook ultimately keys by IDs, so this is not the main issue, but it still creates extra memo/effect churn on rerender. Prefer a memoized array of IDs:

const cameraIds = useMemo(() => cameras.map((camera) => camera.id), [cameras]);
const feedMap = useRoomCameraSnapshots(cameraIds);

Start with the cheap fixes:

  1. Remove the per-frame blink state.
  2. Memoize camera IDs in RoomCameraPanel.
  3. Add client-side render throttling/drop-latest behavior in useRoomCameraSnapshots.
  4. Consider lazy mounting/collapsing room cameras in mobile spectator layout.

Validation

Run:

CPU_THROTTLE=6 VIEWPORT=390x844 MOBILE=1 SAMPLE_MS=30000 \
  node perf/live-root-runtime-audit.mjs https://rover.otter.land/spectate perf/results

Expected improvements:

  • img.h-full.w-full:attributes:src mutations should drop if display is throttled.
  • span.h-2.w-2:attributes:class mutations should disappear if blink state is removed.
  • ScriptDuration and long tasks should improve modestly.
  • Binary socket bytes may stay the same unless server subscription/rate is changed.

Risks

  • Reducing room camera update rate can make the room view feel stale.
  • Object URL lifecycle must stay correct. Do not leak old URLs.
  • If lazy mounting unsubscribes too aggressively, first frame display may feel delayed.