Skip to content

perf: batch WebSocket state updates and session prefetch to reduce monitor view re-renders#143

Merged
streamer45 merged 11 commits intovideofrom
devin/1773486970-monitor-perf-batching
Mar 14, 2026
Merged

perf: batch WebSocket state updates and session prefetch to reduce monitor view re-renders#143
streamer45 merged 11 commits intovideofrom
devin/1773486970-monitor-perf-batching

Conversation

@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor

@staging-devin-ai-integration staging-devin-ai-integration bot commented Mar 14, 2026

Summary

Optimizes performance of session/canvas load in monitor view by reducing unnecessary re-renders during WebSocket state update bursts and ReactFlow node mounting.

Changes

  1. WebSocket RAF batching — Buffer nodestatechanged/nodestatsupdated events and flush via requestAnimationFrame instead of applying individually. Coalesces burst updates into a single Zustand set() per frame via batchUpdateSessionData().

  2. Session prefetch batchinguseSessionsPrefetch now calls batchSetPipelines() instead of N individual setPipeline() calls.

  3. Decouple nodeStates from MonitorViewContent render cycleuseSession no longer subscribes reactively to nodeStates. Instead, MonitorViewContent patches ReactFlow nodes directly via a Zustand store subscription with a 100ms leading+trailing throttle, avoiding full component re-renders on every state change.

  4. Merge startTransition blocks — Combined separate setNodes and setEdges transitions in applyPatch into a single React.startTransition to avoid double render passes.

  5. Low-priority dimension changes — ReactFlow's onNodesChange dimension measurements are wrapped in startTransition so React schedules them at lower priority, avoiding main thread blocking during node mount measurement.

  6. Dev-only React.Profiler — Wraps MonitorView with <React.Profiler> (dev builds only) to enable window.__PERF_DATA__ capture.

  7. E2E perf testmonitor-session-load-perf.spec.ts covering the session load scenario.

Results (7 profiling iterations)

Profile Commits Total (ms) Max (ms) MVC re-renders MVC time (ms)
1 (baseline) 95 1254 387 18 507
7 (final) 96 1066 314 10 361
Δ +1 −188 (−15%) −73 (−19%) −8 (−44%) −146 (−29%)

The remaining ~1066ms is dominated by two unavoidable ReactFlow mount phases:

Further improvement requires architectural changes (progressive node rendering, deferred edges, lighter monitor-only node components).

Review & Testing Checklist for Human

  • Load a session in monitor view — verify canvas renders correctly with all nodes, edges, and state indicators
  • Verify auto-layout still positions nodes correctly on first load
  • Check that node state transitions (running/stopped) still update node colors in real-time
  • Verify edge alerts (slow-input-timeout) still appear/disappear correctly
  • Test session switching — select different sessions and verify canvas updates
  • Profile with React DevTools to confirm render count reduction
  • Verify audio/video streams don't freeze as much during session load

Notes

  • The onNodesChangeBatched wrapper filters dimension-type changes into startTransition. If any layout glitches appear (e.g., edges not connecting properly), the dimension deferral may need to be removed.
  • The throttle on the store subscription (100ms) means rapid state changes may appear with slight delay. This is intentional to reduce render frequency during bursts.

Link to Devin session: https://staging.itsdev.in/sessions/787eeb5029944572ac47a622deb0a9c3
Requested by: @streamer45


Staging: Open in Devin

…nitor view re-renders

- Buffer nodestatechanged/nodestatsupdated events in Maps, flush via
  queueMicrotask for a single Zustand set() per session per microtask
- Add batchUpdateNodeStates, batchUpdateNodeStats, batchSetPipelines
  methods to sessionStore for atomic bulk mutations
- Switch useSessionsPrefetch from N individual setPipeline() calls to
  one batchSetPipelines() call
- Wrap MonitorView with React.Profiler (dev-only) for perf measurement
- Add Layer 2 e2e perf test for monitor session load render budget

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

staging-devin-ai-integration[bot]

This comment was marked as resolved.

queueMicrotask drains after each macrotask, so it cannot coalesce
separate WebSocket onmessage callbacks.  requestAnimationFrame defers
the flush until the next paint, batching all WS events that arrive
within a single animation frame (~16ms at 60fps) into ONE Zustand
set() call via the new batchUpdateSessionData() method.

Also:
- Add batchUpdateSessionData to sessionStore for combined state+stats
  flush in a single set() call
- Clear pending batch buffers on WebSocket close() to prevent stale
  RAF callbacks after teardown
- Assert MonitorView profiler data exists in e2e perf test

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
staging-devin-ai-integration[bot]

This comment was marked as resolved.

streamkit-devin and others added 5 commits March 14, 2026 11:38
- Fix stale comment: 'microtask-level' → 'frame-level' to match RAF impl
- Clear pendingNodeStates/pendingNodeStats on session destroy
- Update websocket tests to manually flush RAF batch before assertions

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Replace reactive useSession(nodeStates) subscription with a direct
Zustand store subscription that patches ReactFlow nodes and edges
from the callback.  This completely bypasses React's render cycle
for the ~3600-line MonitorViewContent component during high-frequency
node-state transitions.

Before: each node state change (e.g. Initializing → Running) caused
a full MonitorViewContent re-render (~25-40ms), multiplied by ~10
nodes during session load.

After: the store subscription fires an O(1) reference check per store
change and only patches the affected ReactFlow nodes/edges via
startTransition, with zero MonitorViewContent re-renders.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
During session load, ~8 nodes transition state on separate animation
frames, each triggering a ~20ms MonitorViewContent re-render via
setNodes/setEdges.  Add a leading-edge + trailing-edge throttle
(100ms window) so that the first change applies immediately and
subsequent rapid changes are coalesced into 2-3 patches instead
of 8.

Expected reduction: ~160ms of MonitorViewContent re-renders collapsed
to ~40-60ms during session load, while steady-state changes still
apply within one throttle window.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
ReactFlow fires individual onNodesChange callbacks with type='dimensions'
for each newly-mounted node as it measures them. Each dimension change
triggers a setNodes update, causing a full MonitorViewContent re-render
(~20ms each). During session load with ~8 nodes, this creates ~8
consecutive re-renders totaling ~160ms of wasted render time.

Two optimizations:
1. Intercept onNodesChange and collect all dimension-type changes, then
   flush them in a single RAF callback wrapped in startTransition. This
   collapses ~8 separate renders into 1.
2. Merge the two separate startTransition blocks in applyPatch (one for
   setNodes, one for setEdges) into a single startTransition so React
   batches both state updates into one render pass instead of two.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
The previous RAF-based dimension batching collected all dimension changes
and flushed them in a single commit.  Profiling showed this created a
190ms jank (commit #55) — worse than the original 8 × ~20ms spread across
multiple frames.

Replace with a simpler approach: wrap dimension changes in
React.startTransition so React schedules them at lower priority.  This
avoids concentrating all dimension work into one frame while still keeping
interactive changes (select, drag, remove) immediate.

Also keeps the merged startTransition for setNodes+setEdges in
applyPatch (from the previous commit) which avoids double renders
from the subscription path.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 15 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

Comment on lines +1504 to +1528
const onNodesChangeBatched = useCallback(
(changes: NodeChange[]) => {
const immediate: NodeChange[] = [];
const deferred: NodeChange[] = [];

for (const c of changes) {
if (c.type === 'dimensions') {
deferred.push(c);
} else {
immediate.push(c);
}
}

if (immediate.length > 0) {
onNodesChangeInternal(immediate);
}

if (deferred.length > 0) {
React.startTransition(() => {
onNodesChangeInternal(deferred);
});
}
},
[onNodesChangeInternal]
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 startTransition for ReactFlow dimension changes may delay measured node sizes

The new onNodesChangeBatched callback at ui/src/views/MonitorView.tsx:1504-1528 defers 'dimensions' type changes via React.startTransition. ReactFlow fires these after measuring DOM node sizes (e.g., for auto-layout and edge routing). By deferring them, there's a brief window where ReactFlow's internal state doesn't reflect actual node dimensions. This is likely benign since the nodes are already visually rendered at their correct size, and the deferred update just records the measurement. However, if any code between the dimension measurement and the deferred application depends on having accurate measured dimensions in the ReactFlow node objects (e.g., the auto-layout effect at line 3355 which uses collectNodeHeights), there could be a brief inconsistency.

Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation. The auto-layout effect at line 3355 (collectNodeHeights) reads dimensions from the nodes array. With startTransition, there's a brief window where node.measured might not be populated yet. In practice the auto-layout effect runs after the initial mount + dimension pass, so it should see the final values. But I'll keep an eye on this during testing — if layout glitches appear we can exempt the initial dimension pass from the transition.

streamkit-devin and others added 2 commits March 14, 2026 12:04
Address review feedback: store the requestAnimationFrame ID so it can
be cancelled via cancelAnimationFrame in close(), rather than relying
on cleared maps to make the stale callback a no-op.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
The permanent <React.Profiler id='MonitorView'> wrapper caused a
cascade regression in compositor-perf.spec.ts.  Since MonitorView wraps
the entire ReactFlow tree (including CompositorNode), every slider-drag
commit fired the MonitorView onRender callback, making it appear as a
cascade when it was just the outer profiler boundary counting all
child commits.

Remove the permanent Profiler from MonitorView.  Update the
monitor-session-load-perf test to assert on CompositorNode profiler
data (which has its own Profiler) instead of MonitorView.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

Comment on lines 2841 to +2850
useEffect(() => {
if (!pipeline) return;
if (!selectedSessionId) return;

// Skip on initial mount - let topology effect handle everything
if (isInitialMountRef.current) {
viewsLogger.debug('Skipping patch effect on initial mount');
isInitialMountRef.current = false;
prevTopoKeyRef.current = topoKey;
return;
}
const PATCH_THROTTLE_MS = 100;

// If topoKey changed, the topology effect will handle the full rebuild, skip this patch
if (prevTopoKeyRef.current !== topoKey) {
viewsLogger.debug(
'Skipping patch effect, topology changed (prev:',
prevTopoKeyRef.current.substring(0, 30),
'new:',
topoKey.substring(0, 30),
')'
);
prevTopoKeyRef.current = topoKey;
return;
}
let prevNodeStates: Record<string, NodeState> | undefined;
let lastPatchTime = 0;
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
let pendingNodeStates: Record<string, NodeState> | null = null;
isInitialMountRef.current = true;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Zustand subscription uses stale selectedSessionId after session switch

The useEffect at ui/src/views/MonitorView.tsx:2841 that creates the Zustand useSessionStore.subscribe() callback captures selectedSessionId in its closure. The effect's dependency array is [selectedSessionId, setNodes, setEdges], so it correctly re-creates the subscription when the session changes. However, isInitialMountRef.current is reset to true on every re-subscription (line 2850), but prevTopoKeyRef and topoEffectRanRef are not reset. After switching sessions, topoEffectRanRef.current may still be true from the previous session, and prevTopoKeyRef.current may hold the old session's topoKey. This means the very first store notification for the new session could skip the initial-mount guard (since isInitialMountRef is set to false on the first callback invocation) but then pass the topoEffectRanRef check and attempt to applyPatch before the topology effect has run for the new session. The applyPatch function reads pipelineRef.current, which at that moment may still hold the old session's pipeline. This results in a brief flash where nodes from the new session are patched with state/params derived from the old session's pipeline data, causing incorrect node state display until the topology effect runs and rebuilds the graph.

Suggested change
useEffect(() => {
if (!pipeline) return;
if (!selectedSessionId) return;
// Skip on initial mount - let topology effect handle everything
if (isInitialMountRef.current) {
viewsLogger.debug('Skipping patch effect on initial mount');
isInitialMountRef.current = false;
prevTopoKeyRef.current = topoKey;
return;
}
const PATCH_THROTTLE_MS = 100;
// If topoKey changed, the topology effect will handle the full rebuild, skip this patch
if (prevTopoKeyRef.current !== topoKey) {
viewsLogger.debug(
'Skipping patch effect, topology changed (prev:',
prevTopoKeyRef.current.substring(0, 30),
'new:',
topoKey.substring(0, 30),
')'
);
prevTopoKeyRef.current = topoKey;
return;
}
let prevNodeStates: Record<string, NodeState> | undefined;
let lastPatchTime = 0;
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
let pendingNodeStates: Record<string, NodeState> | null = null;
isInitialMountRef.current = true;
useEffect(() => {
if (!selectedSessionId) return;
const PATCH_THROTTLE_MS = 100;
let prevNodeStates: Record<string, NodeState> | undefined;
let lastPatchTime = 0;
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
let pendingNodeStates: Record<string, NodeState> | null = null;
isInitialMountRef.current = true;
topoEffectRanRef.current = false;
Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 65aee50. Added topoEffectRanRef.current = false on subscription re-creation so the guard works correctly after session switches.

After switching sessions, topoEffectRanRef could still be true from the
previous session, allowing the store subscription to apply patches using
the old pipeline data before the topology effect runs for the new
session.  Reset it to false when the subscription re-creates.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
staging-devin-ai-integration[bot]

This comment was marked as resolved.

The previous commit reset topoEffectRanRef.current = false in the
subscription effect setup.  Since React runs effects in declaration
order, the topology effect sets it to true first, then the subscription
effect immediately resets it to false — permanently blocking all
subsequent nodeState patches (the subscription callback checks
topoEffectRanRef.current before applying patches).

The stale-data case is already handled by the isInitialMountRef guard
and the pipelineRef.current null check inside applyPatch, so the
reset is unnecessary.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 16 additional findings in Devin Review.

Staging: Open in Devin
Debug

Playground

let lastPatchTime = 0;
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
let pendingNodeStates: Record<string, NodeState> | null = null;
isInitialMountRef.current = true;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Store subscription's topoEffectRanRef is never reset on session switch, potentially blocking node-state patches

When the selectedSessionId changes, the useEffect at ui/src/views/MonitorView.tsx:2841 tears down and recreates the Zustand store subscription. It correctly resets isInitialMountRef.current = true (line 2850), but topoEffectRanRef (declared at line 2822) is never reset to false. The subscription callback at line 2999 checks if (!topoEffectRanRef.current) return; to avoid patching before the topology effect builds the initial graph. Because topoEffectRanRef retains true from the previous session, the new subscription may attempt to patch nodes before the topology effect has run for the new session, applying stale node states to the old (or empty) node array. The isInitialMountRef check on the first callback provides partial protection, but subsequent rapid store notifications (e.g. from the RAF batch flush) can arrive before the topology effect completes in a startTransition, bypassing the guard and calling applyPatch with the old session's nodes still in the ReactFlow state.

Suggested change
isInitialMountRef.current = true;
isInitialMountRef.current = true;
topoEffectRanRef.current = false;
Staging: Open in Devin

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the exact opposite suggestion from the previous review (which flagged topoEffectRanRef.current = false as a 🔴 bug because it permanently blocks patches when the topology effect runs before the subscription effect). We tried adding this line in 65aee50 and reverted it in 4f4bc7c because of that issue.

The applyPatch function already has if (!currentPipeline) return; at line 2856 which guards against patching with stale data when the pipeline hasn't loaded yet. The isInitialMountRef guard skips the first notification. And because the topology effect uses startTransition, it runs in the same React batch — by the time the subscription's applyPatch runs (which also uses startTransition), the pipeline ref is already updated. So the current code is safe without resetting topoEffectRanRef.

@streamer45 streamer45 merged commit 93e27ca into video Mar 14, 2026
1 check was pending
@streamer45 streamer45 deleted the devin/1773486970-monitor-perf-batching branch March 14, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants