Files
Codeman/docs/archive/perf-audit-first-load.md
T
arkonandClaude Opus 4.7 1c3f2f6571 docs: tighten CLAUDE.md and archive 22 completed plan docs
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>
2026-04-23 11:14:07 +02:00

16 KiB

Performance Audit: First Page Load

Date: 2026-02-18 Scope: Browser first-load of Codeman web UI (/) Method: Static analysis by 4 parallel audit agents (server, frontend, SSE/xterm, asset pipeline)


Current State Summary

Payload Sizes (measured from live server, port 3000)

Asset Raw Size Gzip Brotli Lines Render-Blocking?
index.html 82 KB 15 KB 15 KB 1,479 N/A (document)
app.js 562 KB 126 KB 125 KB 15,354 No (defer)
styles.css 154 KB 25 KB 27 KB 8,199 YES
mobile.css 34 KB 7 KB 7 KB 1,493 YES (no media query!)
xterm.css (CDN) 2 KB 2 KB — — YES (external CDN)
xterm.min.js (CDN) 67 KB 65 KB — — No (defer)
xterm-addon-fit (CDN) 1 KB 1 KB — — No (defer)
Total local 832 KB 173 KB 174 KB
Total w/ CDN ~902 KB ~241 KB

Server compression: Brotli preferred (Content-Encoding: br), via @fastify/compress with threshold 1024. Compression is on-the-fly per request — no pre-compressed files exist.

HTTP headers verified: Cache-Control: public, max-age=3600, weak ETags auto-generated by @fastify/static, Vary: accept-encoding, CSP + security headers present.

Request Waterfall on First Load (6-7 API calls!)

Browser hits /
├── index.html ............................ (82 KB document)
├── styles.css?v=0.1533 .................. (render-blocking CSS, 154 KB)
├── mobile.css?v=0.1533 .................. (render-blocking CSS, 34 KB — wasted on desktop!)
├── xterm.css (CDN) ...................... (render-blocking CSS — external!)
├── xterm.min.js (CDN, defer) ........... (67 KB, parallel download)
├── xterm-addon-fit.min.js (CDN, defer) .. (1 KB, parallel download)
├── app.js?v=0.1533 (defer) ............. (562 KB, parallel download)
│
│   [FIRST PAINT blocked until ALL CSS downloaded + parsed]
│
├── JS executes: new CodemanApp().init()
│   ├── initTerminal() ................... (SYNC: new Terminal() + terminal.open() → canvas creation)
│   ├── connectSSE() → /api/events ....... (SSE → fires 'init' with getLightState())
│   ├── loadState() → /api/status ........ (DUPLICATE #1: same data as SSE init!)
│   ├── loadQuickStartCases()
│   │   ├── /api/settings ................ (settings fetch #1)
│   │   └── /api/cases?_t=<timestamp> ... (case list, cache-busted!)
│   ├── startSystemStatsPolling() → /api/system/stats (every 2s, starts immediately)
│   └── loadAppSettingsFromServer() → /api/settings (DUPLICATE #2: settings fetched again!)

Total init API calls: 6-7 requests, with 2 duplicates (/api/status = SSE init, /api/settings fetched twice).

Critical Path Bottlenecks

  1. 3 render-blocking CSS files (one from CDN, one wasted on desktop)
  2. Synchronous terminal.open() blocks main thread during init (canvas creation)
  3. Double handleInit() execution — SSE init + /api/status both call it, causing full state reset + cleanup twice within ~100ms
  4. /api/settings fetched twice — once in loadQuickStartCases(), once in loadAppSettingsFromServer()
  5. No loading skeleton — blank #0a0a0a screen until CSS+JS fully loaded
  6. 12 modals pre-rendered in HTML — ~600+ DOM elements, ~60KB of invisible HTML
  7. 562KB monolith app.js unminified — 1,525 comment lines (10%), 89 console.* statements, 23% whitespace
  8. No minification in build — cp -r copies raw source to dist
  9. Stats polling starts immediately — 2s interval even with no sessions
  10. Version query strings stale — HTML has ?v=0.1533, package.json is 0.1534

What's Already Good

  • Only 1 xterm Terminal instance shared across all sessions (buffer swapping on tab switch)
  • Teammate terminals created lazily on window open (with requestAnimationFrame defer)
  • Subagent windows use HTML activity logs, not additional Terminal instances
  • getLightState() has a 1-second TTL cache — no duplicate server-side computation
  • SSE init sends lightweight state (no terminal buffers) — buffers fetched on-demand per tab
  • Buffer hydration uses chunked writes (128KB chunks via requestAnimationFrame) — no UI jank
  • selectSession() defers secondary panels via requestIdleCallback
  • Buffer fetch is tail-mode (last 256KB only, not full 2MB)
  • System fonts only — no web font downloads blocking paint
  • All JS scripts use defer
  • SSE reconnection has proper exponential backoff with timeout cleanup

Optimization Plan

Phase 1: Quick Wins (High Impact, Low Effort)

1.1 Add media attribute to mobile.css

Impact: HIGH — 34KB CSS stops blocking render on desktop Effort: 1 line change File: src/web/public/index.html:14

<!-- Before -->
<link rel="stylesheet" href="mobile.css?v=...">

<!-- After -->
<link rel="stylesheet" href="mobile.css?v=..." media="(max-width: 1023px)">

The browser still downloads it (for potential resize) but won't block rendering on desktop. The mobile.css comment on line 4 says this was intended but never implemented.

1.2 Eliminate duplicate /api/status fetch + double handleInit()

Impact: HIGH — removes 1 redundant API call + eliminates double state reset (clearing 15+ Maps, 7+ timers, cleanupAllFloatingWindows(), double renderSessionTabs(), double async subagent restore chain) Effort: Small Files: src/web/public/app.js:1554, app.js:3566-3574

The SSE init event (server.ts:618) already sends getLightState(). The loadState() at app.js:1554 fetches identical data from /api/status. Both call handleInit() which does a full state reset — whichever arrives second wipes all state from the first and rebuilds from scratch.

The _initGeneration guard (line 3373/3549) only protects the session-restore at the end, NOT the expensive full cleanup (lines 3389-3503).

Approach: Remove this.loadState() from init(). Add a fallback timeout:

// In init():
this.connectSSE();
// Remove: this.loadState();
this._initFallbackTimer = setTimeout(() => {
  if (this._initGeneration === 0) this.loadState();
}, 3000);

Clear the timer in handleInit():

handleInit(data) {
  if (this._initFallbackTimer) {
    clearTimeout(this._initFallbackTimer);
    this._initFallbackTimer = null;
  }
  // ... rest of handleInit
}

1.3 Deduplicate /api/settings fetch

Impact: MEDIUM — removes 1 redundant API call Effort: Small Files: src/web/public/app.js:7341 (in loadQuickStartCases), app.js:9964 (in loadAppSettingsFromServer)

Both fetch /api/settings. Fetch it once, pass the result to both consumers:

// In init():
const settingsPromise = fetch('/api/settings').then(r => r.json());
this.loadQuickStartCases(null, settingsPromise);
this.loadAppSettingsFromServer(settingsPromise);

1.4 Defer system stats polling

Impact: MEDIUM — removes 1 API call every 2s when idle Effort: Small Files: src/web/public/app.js:1567, app.js:15261-15271

fetchSystemStats() already has a visibility guard (line 15282: skips if #headerSystemStats is display: none), but the interval still ticks. Move startSystemStatsPolling() out of init() — start it in handleInit() only when data.sessions.length > 0.

1.5 Preload xterm.css to unblock render

Impact: MEDIUM — external CDN CSS currently blocks first paint Effort: 2 line change File: src/web/public/index.html:15

<!-- Before -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">

<!-- After -->
<link rel="preload" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"></noscript>

Terminal won't display until xterm.js executes anyway, so the CSS doesn't need to block initial paint.

1.6 Fix stale version query strings

Impact: LOW — prevents serving cached stale assets after deploy Effort: Small File: COM script in CLAUDE.md

The HTML references ?v=0.1533 while package.json is already at 0.1534. The COM workflow should auto-update HTML version strings. Add to the COM script:

# After incrementing version in package.json + CLAUDE.md:
sed -i "s/?v=[0-9.]*/?v=$NEW_VERSION/g" src/web/public/index.html

1.7 Remove cache-busting from /api/cases

Impact: LOW — allows HTTP caching of case list Effort: 1 line change File: src/web/public/app.js:7351

// Before:
const res = await fetch('/api/cases?_t=' + Date.now());
// After:
const res = await fetch('/api/cases');

The case list rarely changes during a session. Let the browser cache it.


Phase 2: Medium Effort (High Impact)

2.1 Add loading skeleton

Impact: MEDIUM-HIGH — perceived performance improvement (instant visual structure) Effort: Small-Medium File: src/web/public/index.html

Add minimal inline <style> + skeleton HTML in <body> showing a dark header bar + terminal placeholder. Hidden by app.js once init completes:

<style>
  .skeleton { display: flex; flex-direction: column; height: 100vh; }
  .skeleton-header { height: 40px; background: #111; border-bottom: 1px solid #222; }
  .skeleton-terminal { flex: 1; background: #0d0d0d; }
  .app-loaded .skeleton { display: none; }
</style>
<div class="skeleton">
  <div class="skeleton-header"></div>
  <div class="skeleton-terminal"></div>
</div>

In app.js init(), add document.body.classList.add('app-loaded') at the end.

2.2 Defer xterm.js terminal creation to after first paint

Impact: MEDIUM-HIGH — terminal.open() is the heaviest synchronous call in init Effort: Medium Files: src/web/public/app.js:1545, app.js:1578-1639

init() {
  // ... mobile detection, visibility settings ...
  document.documentElement.classList.remove('mobile-init');

  // Show skeleton/header immediately, defer heavy terminal init
  requestAnimationFrame(() => {
    this.initTerminal();
    this.connectSSE();
    // ... rest of init
  });
}

Lets the browser paint the header/tabs before the terminal canvas is created.

2.3 Batch initial API calls into one endpoint

Impact: MEDIUM — reduces 4+ API calls to 1 Effort: Medium Files: src/web/server.ts, src/web/public/app.js

Create /api/init-bundle:

{
  "status": { /* getLightState() */ },
  "cases": [ /* case list */ ],
  "settings": { /* user settings */ }
}

Replaces /api/status (fallback), /api/cases, /api/settings. Saves HTTP round trips and server-side work.

2.4 Lazy-create modals on first open

Impact: HIGH — removes ~600+ DOM elements from initial parse (~60KB of HTML) Effort: Medium-High Files: src/web/public/index.html, src/web/public/app.js

12 modals pre-rendered in index.html:

  • helpModal (lines 227-447)
  • sessionOptionsModal (lines 448-714) — 266 lines alone
  • appSettingsModal (lines 715-900+)
  • createCaseModal, mobileCasePickerModal, ralphWizardModal, killAllModal, closeConfirmModal, savePresetModal, tokenStatsModal, filePreviewModal, notification drawer

Approach: Replace each modal's HTML with <div id="helpModal" class="modal"></div>. On first open, inject full HTML via createModalContent(). Cache after creation.


Phase 3: Build Pipeline (Highest Impact)

3.1 Self-host xterm.js assets

Impact: MEDIUM — eliminates CDN dependency + latency, enables local caching Effort: Low-Medium Files: src/web/public/index.html, package.json build script

# Build script addition:
mkdir -p dist/web/public/vendor
cp node_modules/xterm/css/xterm.css dist/web/public/vendor/
cp node_modules/xterm/lib/xterm.min.js dist/web/public/vendor/
cp node_modules/@xterm/addon-fit/lib/xterm-addon-fit.min.js dist/web/public/vendor/

Update HTML to reference /vendor/xterm.min.js etc. Removes render-blocking CDN CSS entirely.

3.2 Add esbuild minification to build

Impact: HIGH — ~38 KB compressed savings (16% of local payload) Effort: Medium Files: package.json (build script)

Current build just does cp -r src/web/public dist/web/. No minification at all.

app.js specifics: 1,525 comment lines (10%), 89 console.* statements, 23% whitespace.

# Add to build script:
npx esbuild dist/web/public/app.js --minify --drop:console --outfile=dist/web/public/app.js --allow-overwrite
npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite
npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite

Expected: app.js 562KB → ~350KB minified → ~90KB gzip (from 126KB). --drop:console removes all 89 debug statements.

Note: app.js is vanilla JS (not modules), so esbuild works directly as a minifier.

3.3 Pre-compress static assets at build time

Impact: MEDIUM — eliminates per-request CPU compression work Effort: Low Files: package.json build script, src/web/server.ts

Currently @fastify/compress compresses on-the-fly for every request. Pre-compress at build time:

# Build script:
for f in dist/web/public/*.{js,css,html}; do
  gzip -9 -k "$f"
  brotli -9 -k "$f"
done

Then configure @fastify/static with preCompressed: true (if supported) or serve pre-compressed files via custom logic.

3.4 Extract critical CSS inline

Impact: MEDIUM — eliminates render-blocking styles.css for first paint Effort: Medium-High Files: src/web/public/styles.css, src/web/public/index.html

Identify ~2-3KB of CSS needed for first paint (body, header, tab bar, terminal container) and inline it in <head>. Load full styles.css asynchronously:

<style>/* ~50 lines of critical CSS */</style>
<link rel="preload" href="styles.css?v=..." as="style" onload="this.onload=null;this.rel='stylesheet'">

Impact Estimates

# Optimization First Paint TTI Effort
1.1 mobile.css media query -50ms — 1 min
1.2 Remove duplicate fetch + double handleInit — -100-200ms 15 min
1.3 Deduplicate settings fetch — -50ms 10 min
1.4 Defer stats polling — -20ms 10 min
1.5 Preload xterm.css -100-300ms — 5 min
1.6 Fix stale version strings cache correctness — 5 min
1.7 Remove cases cache-bust — -10ms 1 min
2.1 Loading skeleton perceived -500ms — 30 min
2.2 Defer terminal init -50-100ms -50ms 30 min
2.3 Batch API endpoint — -100-200ms 1 hr
2.4 Lazy modals -30-50ms parse -50ms 2-3 hrs
3.1 Self-host xterm -100-300ms — 20 min
3.2 Minify JS/CSS -50-100ms parse — 30 min
3.3 Pre-compress assets -10-30ms TTFB — 20 min
3.4 Critical CSS inline -200-400ms — 2 hrs

Combined estimate: First paint 300-800ms faster, TTI 200-500ms faster.


Implementation Order (for implementation agent)

Do these in order — each step is independently testable:

  1. 1.1 — mobile.css media query (1 line, instant win)
  2. 1.5 — Preload xterm.css (2 lines, big render-blocking fix)
  3. 1.2 — Remove duplicate /api/status + double handleInit
  4. 1.3 — Deduplicate /api/settings fetch
  5. 1.7 — Remove cache-busting from /api/cases
  6. 3.1 — Self-host xterm.js (removes CDN dependency entirely)
  7. 3.2 — Add esbuild minification to build
  8. 1.4 — Defer stats polling
  9. 1.6 — Fix stale version strings in COM workflow
  10. 2.1 — Loading skeleton
  11. 2.2 — Defer terminal init after first paint
  12. 2.3 — Batch init API endpoint
  13. 2.4 — Lazy modals (biggest refactor, do last)
  14. 3.3 — Pre-compress assets (nice-to-have)
  15. 3.4 — Critical CSS extraction (only if still needed after above)

Verification after each step: Use Playwright to load the page with waitUntil: 'domcontentloaded', measure first paint timing, check that the UI renders correctly with 3-4s wait for async data.