CLAUDE.md: fix stale counts (types 14 to 15, SSE events ~118 to ~120), remove redundant footer sections (References list duplicated inline citations; Common Workflows bullets were self-evident or already stated; Tunnel/Memory Leak Prevention folded into neighboring sections). 251 to 234 lines. Move 22 completed implementation/phase/audit plans to docs/archive/ via git mv so history is preserved. Living reference docs remain in docs/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
Performance Analysis & Optimization Opportunities
Date: 2026-03-07 Scope: Full-stack performance analysis — backend PTY handling, SSE broadcasting, frontend terminal rendering, local echo overlay, DOM updates, config/scaling limits. Constraint: All recommendations preserve existing functionality including local echo, backpressure, anti-flicker pipeline, and mobile support.
Executive Summary
The codebase is already well-optimized in critical paths. The multi-layer backpressure system, adaptive terminal batching, DEC 2026 sync markers, and incremental state serialization are strong. The main opportunities are in reducing unnecessary work (SSE filtering, DOM rebuilds, lazy terminal init) rather than algorithmic changes.
Top 5 high-impact opportunities:
| # | Optimization | Impact | Risk | Effort |
|---|---|---|---|---|
| 1 | Session-scoped SSE subscriptions | Bandwidth -60-80%, CPU -40% | Medium | Medium |
| 2 | Lazy xterm.js for minimized subagent windows | Memory -3.5MB at 50 agents | Low | Low |
| 3 | Targeted badge update (skip full tab rebuild) | Eliminates O(n) reflow on badge change | Low | Low |
| 4 | Conditional SSE padding (tunnel-only, terminal-only) | Bandwidth -70% when tunneled | Low | Low |
| 5 | Canvas renderer on mobile | GPU pressure reduction, battery savings | Low | Low |
1. SSE Broadcasting
Current State
- 92 event types broadcast to all connected clients (max 100)
- Single
JSON.stringify()per event, shared across all clients (efficient) - No per-client filtering — every client receives every event regardless of which session they're viewing
- 8KB padding appended to every event when tunnel is active (forces Cloudflare proxy flush)
- Backpressure: clients marked as backpressured if
reply.raw.write()returns false; recovery viasession:needsRefresh
Bottlenecks
B1: No session-scoped SSE subscriptions (server.ts:1986)
- Client viewing session A still receives all events for sessions B through T
- With 20 active sessions, ~95% of terminal events are irrelevant to any given client
- Cost: wasted bandwidth, CPU for JSON parsing, and event handler dispatch on client
B2: Unconditional 8KB padding (server.ts:1977)
- Every event gets 8KB comment padding when tunnel is active
- A
task:updatedevent (~200 bytes payload) becomes ~8.2KB - High-frequency events like
session:terminalneed the padding; low-frequency events likesession:createddon't
Recommendations
R1: Session-scoped SSE subscriptions (High impact)
- Add
?sessions=id1,id2query param to/api/eventsSSE endpoint - Server filters events by session ID before broadcasting
- Client subscribes to active session + "global" events (session lifecycle, system)
- Re-subscribes on tab switch (or subscribe to all with client-side filter as fallback)
- Savings: ~80% bandwidth reduction for single-session viewers; ~60% for multi-session dashboards
R2: Tiered SSE padding (Medium impact)
- Only pad
session:terminalevents and SSE heartbeats (the two that need proxy flush) - Skip padding for low-frequency structural events (
session:created,task:updated, etc.) - Savings: ~70% padding overhead reduction; terminal events already large enough to flush
2. Terminal Rendering
Current State (Well-Optimized)
- 6-layer anti-flicker pipeline: Server batching (adaptive 16-50ms) → DEC 2026 sync wrap → single JSON serialize → client rAF batching → sync segment parser → chunked buffer loading (32KB/frame)
- 64KB/frame write budget with DEC 2026 sync-segment awareness (prevents 141KB single-frame freezes)
- 3-layer backpressure: SSE cap (128KB queued → drop + refresh), frame budget (64KB/frame), chunked restore (32KB/frame)
- WebGL renderer enabled by default with canvas fallback on context loss
- Typical latency: 16-32ms; worst case: ~115ms (50ms server batch + 50ms sync wait + 16ms rAF)
Bottlenecks
B3: WebGL on mobile (app.js:627-637)
- Mobile GPUs are weaker; WebGL context loss more likely on low-end devices
- Canvas renderer is sufficient for mobile (typically 1 session, smaller viewport)
B4: Static scrollback for all sessions (app.js:572)
- Default 5000 lines scrollback for all sessions regardless of activity level
- Heavy output sessions (build logs, test runners) accumulate large scroll buffers
B5: No addon lazy loading
- FitAddon, Unicode11Addon, and WebGLAddon all loaded at terminal init
- Unicode11Addon only needed for CJK content; WebGLAddon is large
Recommendations
R3: Force canvas renderer on mobile (Low risk)
- Detect
MobileDetection.isMobile()and skip WebGL addon loading - Reduces GPU memory pressure, prevents context loss crashes
- Mobile typically has 1-2 sessions — canvas performance is more than adequate
R4: Dynamic scrollback based on session activity (Low risk)
- Active sessions (working state): 5000 lines (current default)
- Inactive/idle sessions: reduce to 2000 lines
- Restore on session select (fetch from server buffer)
- Savings: ~60% scrollback memory for idle sessions
R5: Lazy-load Unicode11Addon (Low risk)
- Only load when CJK content is detected in terminal output
- Detection: check for characters in CJK Unicode ranges during ANSI stripping (already iterating)
- Most sessions never need it
3. DOM & Session Tab Rendering
Current State
- Session tabs use intelligent incremental updates with debounced 100ms rendering
- Incremental path: only updates changed properties (classes, textContent, badges) when session list is stable
- Full rebuild path: triggered when sessions added/removed or badge count changes
- Subagent windows: per-window xterm.js instances, even when minimized
Bottlenecks
B6: Badge count change triggers full tab rebuild (app.js:3207-3209)
- A single subagent badge increment on one tab triggers
_fullRenderSessionTabs()— rebuilds entire sidebar HTML viainnerHTML = - With 20 sessions, this is an O(n) reflow for a single badge number change
- Badge changes are frequent during active subagent work
B7: Minimized subagent windows retain xterm.js instances (subagent-windows.js)
- 50 subagent windows × ~75KB per xterm.js instance = ~3.75MB DOM memory
- Minimized windows are invisible but their terminals remain in DOM
- xterm.js instances continue processing resize events even when hidden
B8: backdrop-filter: blur() on overlays (styles.css:2246-2247, 3098)
- Forces new stacking context, disables browser compositing optimizations
- 50-100ms layout thrashing on modal open/close
- Only 2 uses, but they're on frequently toggled overlays
Recommendations
R6: Targeted badge update without full rebuild (Low risk)
- When badge count changes but session list is stable, update only the badge
<span>textContent - Keep incremental path for badge changes; only use full rebuild for structural changes (add/remove sessions)
- Savings: Eliminates O(n) reflow per badge change; reduces to O(1) targeted update
R7: Lazy xterm.js initialization for subagent windows (Medium impact)
- Only create xterm.js Terminal instance when window is restored/maximized
- On minimize: serialize terminal buffer, dispose Terminal instance, keep buffer in memory
- On restore: create new Terminal, write buffer back
- Savings: ~3.5MB DOM reduction at 50 minimized agents; eliminates hidden resize processing
- Trade-off: ~200-500ms restore delay (buffer write), mitigated by chunked loading
R8: Replace backdrop-filter: blur() with background: rgba() (Low risk)
- Use semi-transparent background instead of blur effect
- Or use
will-change: transformhint if blur is kept - Savings: Eliminates forced recomposition layer; 50-100ms faster overlay open
4. Backend PTY & State Management
Current State (Excellent)
- BufferAccumulator: Array-based chunking with lazy join on read — avoids O(n) string concatenation
- ANSI stripping: Throttled at 150ms intervals with lazy evaluation (not per-chunk)
- State persistence: 500ms debounce + incremental JSON caching per session (only dirty sessions re-serialized)
- Expensive parsers: Throttled to 150ms window, accumulated data capped at 64KB
- Memory: All buffers have hard limits (2MB terminal, 1MB text, 1000 messages, 64KB line buffer)
Bottlenecks
B9: Pending clean data cap at 64KB (session.ts:1097-1133)
- Between 150ms processing windows, raw PTY data accumulates in
_pendingCleanData - Capped at 64KB — excess data rolls off (old data discarded)
- During heavy output (large build logs), this means parsers may miss content
- Acceptable trade-off for performance, but worth documenting
B10: LRUMap.delete() is O(n) worst case (utils/lru-map.ts:137-138)
- When deleting the newest entry, iterates all keys to find new newest
- Rare in practice (delete is uncommon; set/get are hot paths)
- Could matter during mass cleanup of 500 agents
Recommendations
R9: Consider adaptive pending data cap (Low priority)
- During idle detection (critical to get right), increase cap to 128KB
- During active working state, keep at 64KB (parsers less critical)
- Benefit: More accurate idle detection during heavy output
R10: Track second-newest in LRUMap (Low priority)
- Maintain a
_secondNewestKeyalongside_newestKey - On delete of newest, promote second-newest without iteration
- Only matters at scale (500+ agents with frequent eviction)
5. Local Echo & Input Path
Current State (Well-Designed)
- DOM overlay approach —
<span>elements in.xterm-screenat z-index 7, completely independent ofterminal.write() - Render caching:
_lastRenderKeyincludes text, position, column offsets — skips redundant re-renders - Input flow: Char accumulation → Enter triggers flush → 80ms delay before
\r(ensures text reaches PTY first) - Tab completion: Baseline snapshot → detect buffer change → 300ms fallback timer
- CJK support: Per-character width detection with
terminal.unicode.getStringCellWidth()preferred, manual fallback - Prompt detection: Bottom-up line scan, O(rows) — cached position, column-lock prevents jitter
Bottlenecks
B11: tmux send-keys latency (~50-100ms per input)
- Each
writeViaMux()spawns a child process (tmux send-keys) - Text and Enter sent separately with 50ms delay between
- For rapid typing: characters batch before Enter, so overhead is per-command not per-keystroke
- Acceptable trade-off for session persistence (tmux survives server restarts)
B12: 80ms delay between text flush and Enter (app.js:872-875)
- Intentional: ensures text reaches PTY before Enter, preventing Ink from processing empty input
- Adds 80ms to perceived Enter-to-response latency
- Could potentially be reduced with acknowledgment-based approach
B13: Scroll listener on terminal viewport (zerolag-input-addon.ts:139)
- 50ms debounced re-render on scroll — acceptable but fires frequently during heavy output
- Overlay hidden when scrolled up (correct behavior), shown when at bottom
Recommendations
R11: Reduce Enter delay from 80ms to 50ms (Low risk, test carefully)
- The tmux
send-keysalready has 50ms internal delay - Combined with network latency, 80ms client-side may be excessive
- Test with Ink-heavy sessions (Claude Code's status bar) — if text arrives before Enter at 50ms, reduce
- Savings: 30ms perceived latency reduction per command
R12: Batch tmux send-keys via stdin pipe (Medium effort, high impact for rapid input)
- Instead of spawning
tmux send-keysper input, maintain a persistent connection - Use
tmux -C(control mode) for programmatic interaction without child process spawning - Savings: Eliminate ~50-100ms process spawn overhead per input
- Risk: Control mode has different semantics; needs careful testing with session persistence
R13: Skip overlay re-render during heavy output scroll (Low risk)
- When terminal is receiving >10KB/s output, hide overlay entirely (user isn't typing during heavy output)
- Re-show overlay after 500ms of output silence
- Savings: Eliminates unnecessary DOM overlay re-renders during build logs / test output
6. Polling & File Watchers
Current State
- SubagentWatcher: 1s base poll, full scan throttled to every 5s, fs.watch() on known directories
- TranscriptWatcher: 1 per session, fs.watch() primary with 1s poll fallback
- ImageWatcher: chokidar per session with 100ms stability poll, burst limit 20/10s
- TeamWatcher: chokidar primary with 30s poll fallback, LRU caches (50 teams, 200 tasks)
- RalphTracker: Todo cleanup every 5 minutes
Scaling Profile (20 sessions)
| Component | Instances | Frequency | Total ops/sec |
|---|---|---|---|
| SubagentWatcher | 1 (global) | Full scan every 5s | 0.2/s |
| TranscriptWatcher | 20 | 1s poll (fallback) | 20/s max |
| ImageWatcher | 20 | 100ms poll (during writes only) | 200/s burst |
| TeamWatcher | 1 (global) | 30s poll (fallback) | 0.03/s |
| SSE heartbeat | 1 (global) | 15s | 0.07/s |
| SSE dead client check | 1 (global) | 30s | 0.03/s |
| Mux stats collection | 1 (global) | 2s | 0.5/s |
| Total steady-state | ~21/s |
Recommendations
R14: Increase TranscriptWatcher poll interval to 2s (Low risk)
- Transcript changes are infrequent (new messages every few seconds at most)
- fs.watch() is the primary mechanism; polling is fallback
- Savings: Halves fallback filesystem checks (20/s → 10/s for 20 sessions)
R15: Share chokidar instances for co-located session directories (Medium effort)
- Sessions in the same parent directory could share a single chokidar watcher with depth:3
- Common case: multiple sessions in
~/projects/foo/— one watcher covers all - Savings: Reduce chokidar instances from 20 to ~5-10 for typical workloads
7. Frontend Asset Delivery
Current State
- app.js: 12,027 lines (source) → esbuild minified → gzip/brotli compressed (~30-40KB gzipped)
- Static caching:
maxAge: '1y'via@fastify/static - Service worker: Push notification handler only — no asset caching
- No code splitting: Single monolithic app.js bundle
Bottlenecks
B14: No cache-busting mechanism
maxAge: '1y'means browsers cache aggressively- After deployment, users need
Ctrl+Shift+Rto see updates - No content hash in filenames or ETags for automatic invalidation
B15: Monolithic app.js
- All 12K lines loaded on initial page load regardless of which features are used
- Ralph wizard, plan orchestrator UI, team management — all loaded upfront
- Mobile loads the same bundle as desktop
Recommendations
R16: Add content hash to asset filenames (Medium impact)
- Build step: rename
app.js→app.[hash].js - Generate a manifest or inject hash into HTML template
- Keep
maxAge: '1y'— cache invalidation happens via filename change - Savings: Eliminates stale cache issues after deployment; removes need for manual hard refresh
R17: Code-split app.js into core + feature modules (High effort, medium impact)
- Core (~4K lines): terminal, SSE, session management, tabs, input handling
- Deferred (~8K lines): Ralph wizard, plan UI, team management, subagent windows, image viewer
- Load deferred modules on first use via dynamic
import()or lazy<script>injection - Savings: ~60% reduction in initial load size; faster time-to-interactive
- Risk: Complexity increase; need to handle loading states for deferred features
- Note: May not be worth the effort given the app is already gzipped to ~30-40KB
8. CSS Performance
Current State
- styles.css: 7,153 lines with ~45 box-shadow uses, 2 backdrop-filter uses
- Animations: GPU-accelerated keyframes for pulsing alerts, loading spinners
- Z-index layering: well-organized (subagent 1000, plan 1100, log 2000, image 3000, overlay 7)
Recommendations
R18: Replace backdrop-filter with opaque overlay (Low risk, covered in R8)
R19: Use contain: content on subagent windows (Low risk)
- Add CSS containment to subagent window containers
- Prevents layout changes inside windows from triggering reflow on parent
- Especially valuable with 50 windows: changes in one window won't invalidate others
-
.subagent-window { contain: content; } - Savings: Reduces layout recalculation scope from global to per-window
R20: Use content-visibility: auto on off-screen subagent windows (Low risk)
- Browser skips rendering of off-screen windows entirely
- Combined with
contain-intrinsic-sizeto prevent layout shift -
.subagent-window.minimized { content-visibility: hidden; } - Savings: Browser skips paint/layout for minimized windows; complements R7
9. Memory & Scaling Limits
Current Budget (20 sessions)
| Component | Per Session | Total | Status |
|---|---|---|---|
| Terminal buffer | 2MB | 40MB | Hard-limited, auto-trim |
| Text output | 1MB | 20MB | Hard-limited, auto-trim |
| Messages | ~1MB | 20MB | Capped at 1000, trims to 800 |
| Respawn buffer | 1MB | 20MB | Hard-limited |
| Buffers total | 100MB | Acceptable | |
| TranscriptWatcher | ~100KB | 2MB | |
| ImageWatcher | ~50KB | 1MB | |
| SubagentWatcher | ~500KB | 500KB | Global |
| Frontend terminal cache | ~256KB | 5MB | LRU, max 20 entries |
| Total estimated | ~110MB | Comfortable |
At Max Scale (50 sessions)
- Buffers: ~250MB
- Watchers: ~5MB
- Total: ~255MB + Node.js overhead — acceptable on modern hardware
Potential Leak Vectors (All Mitigated)
_shortIdCachein server — unbounded Map, but entries are tiny (string→string); grows at O(sessions created), not O(events)- All CleanupManager-registered resources tracked and disposed on session stop
isStoppedguard prevents new timers after session cleanup
10. Implementation Priority Matrix
Phase 1 — Quick Wins (1-2 hours each, low risk)
| # | Optimization | Files to Change |
|---|---|---|
| R6 | Targeted badge update | app.js (3207-3209) |
| R3 | Canvas renderer on mobile | app.js (627-637) |
| R8 | Replace backdrop-filter blur | styles.css (2246, 3098) |
| R19 | CSS containment on subagent windows | styles.css |
| R20 | content-visibility: hidden on minimized windows |
styles.css |
Phase 2 — Medium Effort (half-day each)
| # | Optimization | Files to Change |
|---|---|---|
| R2 | Tiered SSE padding | server.ts (broadcast function) |
| R7 | Lazy xterm.js for minimized subagents | subagent-windows.js |
| R11 | Reduce Enter delay to 50ms | app.js (872-875), test with Ink |
| R14 | TranscriptWatcher 2s poll | transcript-watcher.ts |
| R16 | Content-hash asset filenames | build.mjs, server.ts |
Phase 3 — Larger Initiatives (1-2 days each)
| # | Optimization | Files to Change |
|---|---|---|
| R1 | Session-scoped SSE subscriptions | server.ts, app.js (SSE connect) |
| R5 | Lazy Unicode11Addon loading | app.js, build pipeline |
| R12 | Persistent tmux control mode | tmux-manager.ts |
| R17 | Code-split app.js | app.js, build.mjs, HTML template |
Not Recommended (Low ROI or High Risk)
| # | Why Not |
|---|---|
| R4 | Dynamic scrollback adds complexity; memory savings marginal vs total budget |
| R9 | Adaptive pending data cap adds state; current 64KB cap rarely matters |
| R10 | LRUMap.delete() O(n) is theoretical; never triggered at current scale |
| R15 | Shared chokidar instances add directory-matching complexity for minimal gain |
Appendix: Key File Locations
| Area | File | Key Lines |
|---|---|---|
| SSE broadcast | src/web/server.ts |
1961-1989 (broadcast), 1934-1959 (backpressure) |
| Terminal batching | src/web/server.ts |
1994-2048 (per-session adaptive batching) |
| Frame budget | src/web/public/app.js |
1370-1478 (flushPendingWrites, 64KB cap) |
| Flicker filter | src/web/public/app.js |
1176-1255 (50ms sync wait, 256KB safety) |
| Tab rendering | src/web/public/app.js |
3108-3357 (incremental + full rebuild) |
| Tab switching | src/web/public/app.js |
3560-3760 (cache + chunked load + deferred UI) |
| Local echo | packages/xterm-zerolag-input/src/ |
All files (overlay, prompt, CJK) |
| Local echo integration | src/web/public/app.js |
640, 815-988 (input flow) |
| Subagent windows | src/web/public/subagent-windows.js |
Full file (window mgmt, drag, minimize) |
| State persistence | src/state-store.ts |
161-250 (debounced save, incremental JSON) |
| Buffer accumulator | src/utils/buffer-accumulator.ts |
Full file (array chunks, lazy join) |
| PTY handling | src/session.ts |
1046-1133 (data flow), 1173-1230 (parsing) |
| Config limits | src/config/ |
9 files (buffer, map, timing, auth, etc.) |
| Anti-flicker docs | docs/terminal-anti-flicker.md |
Architecture reference |
| CSS | src/web/public/styles.css |
2246 (backdrop-filter), full file |
| Build pipeline | scripts/build.mjs |
59-68 (minify + compress) |