diff --git a/.planning/phases/28-vu-meter-optimization/28-RESEARCH.md b/.planning/phases/28-vu-meter-optimization/28-RESEARCH.md
new file mode 100644
index 000000000..e0af53918
--- /dev/null
+++ b/.planning/phases/28-vu-meter-optimization/28-RESEARCH.md
@@ -0,0 +1,506 @@
+# Phase 28: VU Meter Optimization - Research
+
+**Researched:** 2026-03-03
+**Domain:** React performance optimization for high-frequency UI updates
+**Confidence:** HIGH
+
+## Summary
+
+This phase addresses the root cause of page freezes during long sessions: VU meter updates at 50-70 updates/second flowing through React state management, triggering expensive reconciliation cycles. The current implementation uses `useState` in `useVuHelpers.js` to store VU levels, causing React to re-render every time the native C++ client sends a VU update.
+
+The solution involves three complementary patterns:
+1. **External VU Store** - Store VU data outside React using `useSyncExternalStore` (with shim for React 16 compatibility)
+2. **requestAnimationFrame Batching** - Limit updates to 60fps to match display refresh rate
+3. **Direct DOM Updates via Refs** - Bypass React reconciliation entirely for the visual meter updates
+
+**Primary recommendation:** Create a standalone VU store module that buffers incoming VU data, batches updates with requestAnimationFrame, and exposes a subscription API. VU meter components use refs to directly manipulate DOM elements (CSS classes/styles) rather than relying on React state for visual updates.
+
+## Standard Stack
+
+The established libraries/tools for this domain:
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| use-sync-external-store | ^1.6.0 | useSyncExternalStore shim | Official React shim, works with React 16.8+ |
+| React refs (useRef) | built-in | Direct DOM access | Bypasses reconciliation for high-frequency updates |
+| requestAnimationFrame | browser API | Frame synchronization | Limits updates to 60fps, matches display refresh |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| lodash/throttle | existing | Fallback throttling | If rAF batching insufficient |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| External store | Canvas rendering | More complex, overkill for simple meter |
+| Direct DOM | CSS animations | Less control over frame-by-frame updates |
+| useSyncExternalStore | Zustand | Would add dependency, shim is simpler for this use case |
+
+**Installation:**
+```bash
+cd jam-ui
+npm install use-sync-external-store
+```
+
+## Architecture Patterns
+
+### Recommended Project Structure
+```
+src/
+├── stores/
+│ └── vuStore.js # External VU store (NEW)
+├── hooks/
+│ ├── useVuHelpers.js # MODIFY: Remove useState, use external store
+│ └── useVuStore.js # NEW: Hook wrapping external store subscription
+├── components/
+│ └── client/
+│ └── SessionTrackVU.js # MODIFY: Use refs for DOM updates
+└── context/
+ └── VuContext.js # MODIFY: Simplified, provides store access
+```
+
+### Pattern 1: External Store with requestAnimationFrame Batching
+**What:** A plain JavaScript module that holds VU state outside React, batches incoming updates, and notifies subscribers on each animation frame.
+**When to use:** When data arrives faster than React can efficiently render (50+ updates/sec).
+**Example:**
+```javascript
+// Source: React official docs + best practices for high-frequency data
+// src/stores/vuStore.js
+
+let vuLevels = {}; // { mixerId: { level: 0, clipping: false, timestamp: 0 } }
+let listeners = new Set();
+let pendingUpdates = {};
+let rafId = null;
+
+function emitChange() {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function flushUpdates() {
+ // Merge pending updates into vuLevels
+ if (Object.keys(pendingUpdates).length > 0) {
+ vuLevels = { ...vuLevels, ...pendingUpdates };
+ pendingUpdates = {};
+ emitChange();
+ }
+ rafId = requestAnimationFrame(flushUpdates);
+}
+
+export const vuStore = {
+ // Called by native client bridge at 50-70/sec
+ updateLevel(mixerId, level, clipping) {
+ pendingUpdates[mixerId] = { level, clipping, timestamp: performance.now() };
+ // Don't emit - rAF loop handles it
+ },
+
+ removeLevel(mixerId) {
+ delete pendingUpdates[mixerId];
+ if (vuLevels[mixerId]) {
+ vuLevels = { ...vuLevels };
+ delete vuLevels[mixerId];
+ emitChange();
+ }
+ },
+
+ getSnapshot() {
+ return vuLevels; // Must return same reference if unchanged
+ },
+
+ subscribe(listener) {
+ listeners.add(listener);
+ // Start rAF loop when first subscriber
+ if (listeners.size === 1 && !rafId) {
+ rafId = requestAnimationFrame(flushUpdates);
+ }
+ return () => {
+ listeners.delete(listener);
+ // Stop rAF loop when no subscribers
+ if (listeners.size === 0 && rafId) {
+ cancelAnimationFrame(rafId);
+ rafId = null;
+ }
+ };
+ },
+
+ // For single meter subscription (more efficient)
+ getLevelSnapshot(mixerId) {
+ return vuLevels[mixerId] || null;
+ }
+};
+```
+
+### Pattern 2: Direct DOM Updates with Refs
+**What:** VU meter components use refs to directly manipulate DOM elements, bypassing React reconciliation.
+**When to use:** For visual updates that happen more than 30 times per second.
+**Example:**
+```javascript
+// Source: React performance best practices
+// Direct DOM manipulation for VU lights
+
+function VuMeterDirect({ mixerId, lightCount = 16 }) {
+ const containerRef = useRef(null);
+ const lightsRef = useRef([]);
+
+ // Subscribe to just this mixer's data
+ useEffect(() => {
+ let rafId = null;
+
+ const updateVisuals = () => {
+ const data = vuStore.getLevelSnapshot(mixerId);
+ if (!data || !containerRef.current) return;
+
+ const { level, clipping } = data;
+ const activeLights = Math.round(level * lightCount);
+
+ // Direct DOM manipulation - no React involvement
+ lightsRef.current.forEach((light, i) => {
+ if (!light) return;
+ const isActive = (lightCount - 1 - i) < activeLights;
+ const positionFromBottom = lightCount - 1 - i;
+
+ // Remove all state classes
+ light.classList.remove('vu-bg-success', 'vu-bg-warning', 'vu-bg-danger', 'vu-bg-secondary');
+
+ if (isActive) {
+ if (clipping) {
+ light.classList.add('vu-bg-danger');
+ } else if (positionFromBottom >= Math.floor(lightCount * 0.75)) {
+ light.classList.add('vu-bg-danger');
+ } else if (positionFromBottom >= Math.floor(lightCount * 0.5)) {
+ light.classList.add('vu-bg-warning');
+ } else {
+ light.classList.add('vu-bg-success');
+ }
+ } else {
+ light.classList.add('vu-bg-secondary');
+ }
+ });
+
+ rafId = requestAnimationFrame(updateVisuals);
+ };
+
+ rafId = requestAnimationFrame(updateVisuals);
+ return () => cancelAnimationFrame(rafId);
+ }, [mixerId, lightCount]);
+
+ // Render once, never re-render for VU updates
+ return (
+
+
+ {Array.from({ length: lightCount }).map((_, i) => (
+
lightsRef.current[i] = el}
+ className="vu-bg-secondary"
+ style={{ height: '10px', width: '25px', marginTop: '1px', borderRadius: '2px', border: '1px solid #eee' }}
+ />
+ ))}
+
+
+ );
+}
+```
+
+### Pattern 3: useSyncExternalStore Hook Wrapper
+**What:** A custom hook that safely subscribes React components to the external store.
+**When to use:** When components need to respond to VU changes (e.g., clipping indicators that need to trigger other UI).
+**Example:**
+```javascript
+// Source: React official documentation for useSyncExternalStore
+// src/hooks/useVuStore.js
+
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+import { vuStore } from '../stores/vuStore';
+
+// Subscribe to all VU levels (use sparingly - causes re-renders)
+export function useAllVuLevels() {
+ return useSyncExternalStore(
+ vuStore.subscribe,
+ vuStore.getSnapshot
+ );
+}
+
+// Subscribe to single mixer - more efficient
+export function useVuLevel(mixerId) {
+ const getSnapshot = useCallback(() => vuStore.getLevelSnapshot(mixerId), [mixerId]);
+ return useSyncExternalStore(
+ vuStore.subscribe,
+ getSnapshot
+ );
+}
+```
+
+### Anti-Patterns to Avoid
+- **useState for VU levels:** Creates new state on every update, triggers reconciliation
+- **Redux for VU data:** 50-70 actions/sec overwhelms Redux DevTools and middleware
+- **Context for high-frequency updates:** Each update re-renders all consumers
+- **Creating new objects in getSnapshot:** Must return same reference if data unchanged
+- **Multiple rAF loops:** Centralize animation handling, don't create per-component loops
+
+## Don't Hand-Roll
+
+Problems that look simple but have existing solutions:
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| External store subscription | Custom subscription system | useSyncExternalStore shim | Handles concurrent rendering edge cases |
+| Frame rate limiting | setInterval/setTimeout | requestAnimationFrame | Syncs with display refresh, pauses when tab hidden |
+| React 16 compatibility | Custom useSyncExternalStore | use-sync-external-store npm | Official React team maintained |
+
+**Key insight:** The combination of external store + rAF batching + direct DOM refs is a well-established pattern for this exact problem (high-frequency visual updates). The useSyncExternalStore shim handles React's concurrent rendering complexities that are easy to get wrong with a custom implementation.
+
+## Common Pitfalls
+
+### Pitfall 1: getSnapshot Returns New Object Every Call
+**What goes wrong:** Infinite re-render loop or excessive renders
+**Why it happens:** useSyncExternalStore uses Object.is() comparison; new objects always differ
+**How to avoid:** Store immutable snapshots, return the stored reference directly
+**Warning signs:** React DevTools shows constant re-renders, console shows "maximum update depth exceeded"
+
+### Pitfall 2: subscribe Function Recreated Every Render
+**What goes wrong:** Unnecessary unsubscribe/resubscribe on every render
+**Why it happens:** Defining subscribe inside component creates new function reference each render
+**How to avoid:** Define subscribe outside component or use useCallback with stable dependencies
+**Warning signs:** VU meters flicker or drop updates during other component updates
+
+### Pitfall 3: Memory Leaks from Uncancelled Animation Frames
+**What goes wrong:** rAF callbacks continue after component unmount, accessing null refs
+**Why it happens:** Missing cleanup in useEffect, or cleanup doesn't cancel all pending frames
+**How to avoid:** Store rafId in ref, cancel in cleanup, check refs before accessing
+**Warning signs:** Console errors about "cannot read property of null" after leaving session
+
+### Pitfall 4: Blocking Main Thread with DOM Reads/Writes
+**What goes wrong:** Layout thrashing causes jank even at 60fps
+**Why it happens:** Interleaving offsetWidth reads with style writes forces browser reflow
+**How to avoid:** Batch all reads, then all writes; use classList instead of style for class changes
+**Warning signs:** React DevTools Profiler shows long "Commit" times, browser DevTools shows forced reflow
+
+### Pitfall 5: Not Handling Missing Mixers
+**What goes wrong:** VU meter crashes when mixer removed mid-session
+**Why it happens:** Native client may remove mixer before React component unmounts
+**How to avoid:** Always check if mixer/level exists before updating, handle null gracefully
+**Warning signs:** Console errors during session participant leave
+
+## Code Examples
+
+Verified patterns from official sources:
+
+### Bridge Callback Integration
+```javascript
+// Source: Current codebase pattern + optimization
+// Modify handleBridgeCallback in useMixerStore.js
+
+import { vuStore } from '../stores/vuStore';
+
+const handleBridgeCallback = useCallback((vuData) => {
+ for (const vuInfo of vuData) {
+ const eventName = vuInfo[0];
+ if (eventName === "vu") {
+ const mixerId = vuInfo[1];
+ const mode = vuInfo[2];
+ const leftValue = vuInfo[3];
+ const leftClipping = vuInfo[4];
+
+ // Convert dB to 0.0-1.0 range
+ const normalizedLevel = (leftValue + 80) / 80;
+
+ // Create qualified ID matching current pattern
+ const fqId = (mode ? 'M' : 'P') + mixerId;
+
+ // Update external store instead of React state
+ vuStore.updateLevel(fqId, normalizedLevel, leftClipping);
+ }
+ }
+}, []);
+```
+
+### VU Context Simplified
+```javascript
+// Source: Pattern from codebase + useSyncExternalStore best practices
+// Modify VuContext.js
+
+import React, { createContext, useContext } from 'react';
+import { vuStore } from '../stores/vuStore';
+
+const VuContext = createContext();
+
+export const VuProvider = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const useVuContext = () => {
+ const context = useContext(VuContext);
+ if (!context) {
+ throw new Error('useVuContext must be used within a VuProvider');
+ }
+ return context;
+};
+```
+
+### SessionTrackVU Optimized
+```javascript
+// Source: Current component + direct DOM pattern
+// Modify SessionTrackVU.js
+
+import React, { useEffect, useRef, useCallback, memo } from 'react';
+import { vuStore } from '../../stores/vuStore';
+
+const SessionTrackVU = memo(function SessionTrackVU({
+ lightCount = 16,
+ orientation = 'vertical',
+ lightWidth = 15,
+ lightHeight = 10,
+ mixers
+}) {
+ const containerRef = useRef(null);
+ const lightsRef = useRef([]);
+ const mixerIdRef = useRef(null);
+ const rafIdRef = useRef(null);
+
+ // Update mixerId when mixers change
+ useEffect(() => {
+ const mixer = mixers?.vuMixer;
+ mixerIdRef.current = mixer ? `${mixer.mode ? 'M' : 'P'}${mixer.id}` : null;
+
+ return () => {
+ // Cleanup VU state on unmount
+ if (mixerIdRef.current) {
+ vuStore.removeLevel(mixerIdRef.current);
+ }
+ };
+ }, [mixers]);
+
+ // Animation loop for direct DOM updates
+ useEffect(() => {
+ const updateVisuals = () => {
+ const mixerId = mixerIdRef.current;
+ if (!mixerId) {
+ rafIdRef.current = requestAnimationFrame(updateVisuals);
+ return;
+ }
+
+ const data = vuStore.getLevelSnapshot(mixerId);
+ const level = data?.level ?? 0;
+ const clipping = data?.clipping ?? false;
+ const activeLights = Math.round(level * lightCount);
+
+ // Direct DOM manipulation
+ for (let i = 0; i < lightCount; i++) {
+ const light = lightsRef.current[i];
+ if (!light) continue;
+
+ const positionFromBottom = lightCount - 1 - i;
+ const isActive = positionFromBottom < activeLights;
+
+ // Use classList for efficient class manipulation
+ light.className = isActive
+ ? (clipping || positionFromBottom >= lightCount * 0.75)
+ ? 'vu-light vu-bg-danger'
+ : (positionFromBottom >= lightCount * 0.5)
+ ? 'vu-light vu-bg-warning'
+ : 'vu-light vu-bg-success'
+ : 'vu-light vu-bg-secondary';
+ }
+
+ rafIdRef.current = requestAnimationFrame(updateVisuals);
+ };
+
+ rafIdRef.current = requestAnimationFrame(updateVisuals);
+
+ return () => {
+ if (rafIdRef.current) {
+ cancelAnimationFrame(rafIdRef.current);
+ }
+ };
+ }, [lightCount]);
+
+ // Render only once - never re-renders for VU updates
+ return (
+
+
+ {Array.from({ length: lightCount }).map((_, i) => (
+
lightsRef.current[i] = el}
+ className="vu-light vu-bg-secondary"
+ style={{
+ height: `${lightHeight}px`,
+ width: '25px',
+ marginTop: '1px',
+ borderRadius: '2px',
+ border: '1px solid #eee'
+ }}
+ />
+ ))}
+
+
+ );
+});
+
+export default SessionTrackVU;
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| useState for frequent updates | useSyncExternalStore + refs | React 18 (2022) | Eliminates reconciliation overhead |
+| setInterval for animation | requestAnimationFrame | Browser standard | Syncs with display, battery efficient |
+| Inline styles in render | classList manipulation | Performance best practice | Avoids style recalculation |
+| Context for high-freq data | External stores | React 18 concurrent | Prevents cascade re-renders |
+
+**Deprecated/outdated:**
+- ReactUpdates.injection.injectBatchingStrategy: This was a React internal that older batching solutions used. Not applicable to modern React.
+- componentWillReceiveProps for VU: Lifecycle methods are deprecated; use effects and refs instead.
+
+## Open Questions
+
+Things that couldn't be fully resolved:
+
+1. **Optimal rAF batching threshold**
+ - What we know: 60fps is standard display refresh, native client sends 50-70/sec (150ms refresh rate)
+ - What's unclear: Whether we should batch to exactly 60fps or adapt based on actual update frequency
+ - Recommendation: Start with rAF (60fps cap), measure performance, adjust if needed
+
+2. **React DevTools Profiler verification approach**
+ - What we know: Requirement says "0 re-renders from VU updates"
+ - What's unclear: Exact profiler setup to verify this claim
+ - Recommendation: Document profiler steps in verification task, capture before/after screenshots
+
+3. **Interaction with existing MixersContext re-renders**
+ - What we know: MixersContext was identified as creating new references every render (investigation finding)
+ - What's unclear: Whether VU optimization alone is sufficient or MixersContext needs parallel fixes
+ - Recommendation: VU optimization addresses VU-specific re-renders; MixersContext is separate scope
+
+## Sources
+
+### Primary (HIGH confidence)
+- [React useSyncExternalStore official docs](https://react.dev/reference/react/useSyncExternalStore) - API, patterns, caveats
+- [use-sync-external-store npm (React team)](https://github.com/facebook/react/blob/main/packages/use-sync-external-store/package.json) - Shim compatibility: React 16.8+
+- [MDN requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) - Browser API specification
+
+### Secondary (MEDIUM confidence)
+- [Epic React - useSyncExternalStore Demystified](https://www.epicreact.dev/use-sync-external-store-demystified-for-practical-react-development-w5ac0) - Implementation patterns
+- [OpenReplay - requestAnimationFrame in React](https://blog.openreplay.com/use-requestanimationframe-in-react-for-smoothest-animations/) - Hook patterns and cleanup
+- [SitePoint - Streaming Backends & React](https://www.sitepoint.com/streaming-backends-react-controlling-re-render-chaos/) - High-frequency data patterns
+
+### Tertiary (LOW confidence)
+- WebSearch results for "React VU meter" - Confirmed general patterns but no authoritative 2026 sources
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH - Official React shim documented with version compatibility
+- Architecture: HIGH - Patterns verified against React official docs
+- Pitfalls: MEDIUM - Based on documented useSyncExternalStore caveats + general React performance patterns
+
+**Research date:** 2026-03-03
+**Valid until:** 2026-04-03 (30 days - stable patterns, no breaking changes expected)