- Renamed "Two-Row Tabs" to "Tall Tabs (Name + Folder)" for clarity - Tab folder path now only rendered in JS when setting is ON (not just CSS hidden) - CSS specificity fix: mobile.css now matches .tabs-two-rows specificity (0,2,0) - Server settings sync: display keys seed from server on fresh localStorage - Bumped cache buster versions to 0.1534 to force browser reload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
16 KiB
Performance Audit: First Page Load
Date: 2026-02-18
Scope: Browser first-load of Claudeman 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 ClaudemanApp().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
- 3 render-blocking CSS files (one from CDN, one wasted on desktop)
- Synchronous
terminal.open()blocks main thread during init (canvas creation) - Double
handleInit()execution — SSE init +/api/statusboth call it, causing full state reset + cleanup twice within ~100ms /api/settingsfetched twice — once inloadQuickStartCases(), once inloadAppSettingsFromServer()- No loading skeleton — blank
#0a0a0ascreen until CSS+JS fully loaded - 12 modals pre-rendered in HTML — ~600+ DOM elements, ~60KB of invisible HTML
- 562KB monolith
app.jsunminified — 1,525 comment lines (10%), 89console.*statements, 23% whitespace - No minification in build —
cp -rcopies raw source to dist - Stats polling starts immediately — 2s interval even with no sessions
- Version query strings stale — HTML has
?v=0.1533, package.json is0.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
requestAnimationFramedefer) - 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 viarequestIdleCallback- 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 aloneappSettingsModal(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 — mobile.css media query (1 line, instant win)
- 1.5 — Preload xterm.css (2 lines, big render-blocking fix)
- 1.2 — Remove duplicate
/api/status+ double handleInit - 1.3 — Deduplicate
/api/settingsfetch - 1.7 — Remove cache-busting from
/api/cases - 3.1 — Self-host xterm.js (removes CDN dependency entirely)
- 3.2 — Add esbuild minification to build
- 1.4 — Defer stats polling
- 1.6 — Fix stale version strings in COM workflow
- 2.1 — Loading skeleton
- 2.2 — Defer terminal init after first paint
- 2.3 — Batch init API endpoint
- 2.4 — Lazy modals (biggest refactor, do last)
- 3.3 — Pre-compress assets (nice-to-have)
- 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.