Files
Codeman/docs/first-load-optimization-plan.md
T
arkonandClaude Opus 4.6 c548c2d6ee fix: mobile keyboard handlers killed on SSE reconnect — re-init after cleanup
handleInit() called KeyboardHandler.cleanup() and MobileDetection.cleanup()
on every SSE connect but never re-initialized them, breaking keyboard
scroll-into-view and the /init /clear accessory bar on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:59:42 +01:00

14 KiB

First-Load Performance Optimization Plan

Date: 2026-02-18 Audit by: 4-agent team (css-analyst, js-analyst, server-analyst, deps-analyst) Scope: First browser load of Claudeman web UI at /


Current State (Baseline)

Payload

Asset Raw Compressed Render-Blocking?
index.html 82 KB ~15 KB N/A (document)
styles.css 154 KB ~25 KB YES
mobile.css 34 KB ~7 KB YES (missing media query)
xterm.css (CDN) 2 KB ~2 KB No (preload pattern)
xterm.min.js (CDN) 67 KB ~65 KB No (defer)
xterm-addon-fit (CDN) 1 KB ~1 KB No (defer)
app.js 563 KB ~126 KB No (defer)
Total 903 KB ~241 KB

Request Waterfall (13 requests on first load)

T=0     GET /                              (82KB doc)
T+20ms  ├── styles.css?v=0.1536            (154KB — BLOCKS RENDER)
        ├── mobile.css?v=0.1536            (34KB — BLOCKS RENDER on all viewports!)
        ├── xterm.css (CDN, preloaded)     (2KB — non-blocking, already async)
        ├── xterm.min.js (CDN, defer)      (67KB)
        ├── xterm-addon-fit.min.js (CDN)   (1KB)
        └── app.js?v=0.1536 (defer)        (563KB)

[FIRST PAINT blocked by: styles.css + mobile.css]

T+200ms JS execution starts
        ├── new Terminal() + terminal.open()    ← HEAVY sync (canvas creation)
        ├── connectSSE() → /api/events          ← SSE stream
        ├── loadState() → /api/status           ← DUPLICATE of SSE init!
        ├── loadQuickStartCases()
        │   ├── /api/settings                   ← fetched TWICE
        │   └── /api/cases?_t=<timestamp>       ← cache-busted unnecessarily
        ├── startSystemStatsPolling()
        │   └── /api/system/stats               ← starts immediately, every 2s
        └── loadAppSettingsFromServer()
            └── /api/settings                   ← DUPLICATE #2

T+500ms First Meaningful Paint (terminal + header visible)

Problems

  1. 2 render-blocking CSS files — mobile.css blocks desktop for no reason
  2. Double handleInit() — SSE init + /api/status both call full state reset
  3. Duplicate /api/settings — fetched twice in init chain
  4. 563KB unminified JS monolith — no build minification at all
  5. 154KB unminified CSS — 70% is for modals/wizards (below-the-fold)
  6. Sync terminal.open() — heaviest single call, blocks before first paint
  7. 12 modals pre-rendered — ~600+ hidden DOM nodes, ~60KB HTML
  8. Stats polling starts immediately — even with 0 sessions
  9. No loading skeleton — blank black screen until all CSS+JS loads
  10. CDN dependency — 3 xterm files from jsdelivr (DNS+TLS latency)
  11. 1h cache for versioned assets — could be 1yr+immutable with ?v= busting
  12. No HTTP/2 — 6-connection limit queues some requests
  13. On-the-fly compression — no pre-compressed .gz/.br files

What's Already Good (don't touch)

  • Single shared Terminal instance (buffer swapping)
  • Teammate terminals created lazily on window open
  • Subagent windows use HTML logs, not Terminal instances
  • getLightState() has 1s TTL cache
  • SSE init sends lightweight state (no terminal buffers)
  • Buffer hydration uses chunked writes (128KB via rAF)
  • selectSession() defers secondary panels via requestIdleCallback
  • System fonts only — zero web font loading
  • xterm.css already uses async preload pattern
  • Proper SSE reconnection with exponential backoff
  • CSS contain on header/tabs for layout isolation

Implementation Plan (15 steps, ordered by impact/effort)

Phase 1: Quick Wins (1-line to 15-min changes)

Step 1: Add media attribute to mobile.css

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

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

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

Browser still downloads it (for potential resize) but won't block rendering on desktop. The mobile.css file header says this was intended but never implemented.


Step 2: Remove duplicate /api/status + double handleInit()

Impact: HIGH — eliminates redundant API call + double state reset (clears 15+ Maps, 7+ timers, runs cleanupAllFloatingWindows(), double renderSessionTabs()) Files: src/web/public/app.js

The SSE init event (server.ts:618) sends getLightState(). The loadState() in init() at app.js:1554 fetches identical data from /api/status. Both call handleInit() which wipes state. The _initGeneration guard only protects session-restore, NOT the expensive cleanup (lines 3389-3503).

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

// In handleInit() — clear fallback timer:
handleInit(data) {
  if (this._initFallbackTimer) {
    clearTimeout(this._initFallbackTimer);
    this._initFallbackTimer = null;
  }
  // ... rest of handleInit
}

Step 3: Deduplicate /api/settings fetch

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

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

Both functions need to accept an optional pre-fetched settings promise parameter.


Step 4: Remove cache-busting from /api/cases

Impact: LOW — allows browser caching File: src/web/public/app.js:7351

// BEFORE
const res = await fetch('/api/cases?_t=' + Date.now());
// AFTER
const res = await fetch('/api/cases');

Step 5: Defer system stats polling

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

Move startSystemStatsPolling() out of init(). Start it in handleInit() only when data.sessions.length > 0.


Phase 2: Build Pipeline (30-min changes, highest payload impact)

Step 6: Self-host xterm.js assets

Impact: MEDIUM-HIGH — eliminates CDN DNS/TLS latency (~100ms even with preconnect) Files: src/web/public/index.html, package.json build script

xterm is NOT in package.json — add it:

npm install xterm@5.3.0 @xterm/addon-fit@0.8.0 --save

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 index.html CDN URLs to /vendor/xterm.min.js etc. Remove preconnect/dns-prefetch for jsdelivr.


Step 7: Add esbuild minification to build

Impact: HIGH — biggest single optimization for payload size File: package.json build script

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

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

# Add to build script after cp:
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 savings:

File Before (gzip) After (gzip) Saved
app.js ~126 KB ~85 KB ~41 KB (33%)
styles.css ~25 KB ~18 KB ~7 KB (28%)
mobile.css ~7 KB ~5 KB ~2 KB (29%)
Total ~158 KB ~108 KB ~50 KB

Step 8: Pre-compress static assets at build time

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

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

Check if @fastify/static supports preCompressed: true option. If not, serve pre-compressed files via custom Accept-Encoding check.


Step 9: Extend cache duration for versioned assets

Impact: LOW (first load) / HIGH (repeat visits) File: src/web/server.ts:601

// BEFORE
maxAge: '1h'

// AFTER
maxAge: '1y',
immutable: true

Safe because all assets use ?v=0.1536 cache-busting. First-load unaffected, but all repeat visits serve from disk cache instantly.


Phase 3: Perceived Performance (30-60min, user experience)

Step 10: Add loading skeleton

Impact: MEDIUM-HIGH — instant visual structure instead of black screen File: src/web/public/index.html

Add minimal inline <style> + skeleton HTML in <body>:

<style>
  .skeleton { display: flex; flex-direction: column; height: 100vh; background: #0a0a0a; }
  .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() end: document.body.classList.add('app-loaded');


Step 11: Defer terminal creation to after first paint

Impact: MEDIUM-HIGH — terminal.open() is heaviest sync call File: src/web/public/app.js:1545

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

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

Lets browser paint header/tabs/skeleton before canvas creation.


Phase 4: DOM + Payload Reduction (1-3 hours)

Step 12: Lazy-create modals on first open

Impact: HIGH — removes ~600+ DOM nodes, ~60KB hidden HTML 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
  • appSettingsModal (lines 715-900+)
  • createCaseModal, mobileCasePickerModal, ralphWizardModal, killAllModal, closeConfirmModal, savePresetModal, tokenStatsModal, filePreviewModal, notification drawer

Replace each modal's HTML with <div id="helpModal" class="modal"></div>. On first open, inject full HTML via template function. Cache after creation.


Step 13: Batch initial API calls into one endpoint

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

Create GET /api/init-bundle:

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

Use as SSE init fallback (step 2's timeout). Saves HTTP round trips.


Step 14: Trim SSE init payload

Impact: LOW-MEDIUM — reduces init payload by removing data not needed for first paint File: src/web/server.ts

Remove from SSE init event: taskTree, ralphTodos, ralphTodoStats per session. These can be fetched on-demand when user opens a session's details panel.


Step 15: Enable HTTP/2

Impact: MEDIUM — multiplexed loading over single connection File: src/web/server.ts

// BEFORE
const server = Fastify({ logger: false });

// AFTER (when HTTPS is enabled)
const server = Fastify({
  logger: false,
  http2: true  // Only works with HTTPS
});

Only applicable for --https mode. HTTP/2 multiplexing eliminates the 6-connection limit queuing.


Expected Combined Impact

Metric Before After Improvement
First Paint ~300ms ~100ms -200ms (skeleton visible instantly)
First Contentful Paint ~400ms ~200ms -200ms (no mobile.css blocking desktop)
Time to Interactive ~600ms ~350ms -250ms (fewer API calls, deferred terminal)
Total compressed payload ~241 KB ~191 KB -50 KB (21%) via minification
Init API calls 6-7 (2 dupes) 2-3 -60% fewer requests
Initial DOM nodes ~1800+ ~1200 -600 (lazy modals)

Verification

After each step, verify with Playwright:

const { chromium } = require('playwright');
const browser = await chromium.launch();
const page = await browser.newPage();

// Measure first paint
await page.goto('http://localhost:3000', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000); // Wait for async data

// Check UI renders correctly
const header = await page.locator('.header').isVisible();
const tabs = await page.locator('.session-tabs').isVisible();
const terminal = await page.locator('.terminal-container').isVisible();
console.log({ header, tabs, terminal });

await browser.close();

Files Changed Per Step (for implementation agent)

Step Files Modified
1 index.html
2 app.js
3 app.js
4 app.js
5 app.js
6 index.html, package.json
7 package.json
8 package.json, optionally server.ts
9 server.ts
10 index.html, app.js
11 app.js
12 index.html, app.js
13 server.ts, app.js
14 server.ts
15 server.ts