# 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= ... (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` ```html ``` 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: ```js // In init(): this.connectSSE(); // Remove: this.loadState(); this._initFallbackTimer = setTimeout(() => { if (this._initGeneration === 0) this.loadState(); }, 3000); ``` Clear the timer in `handleInit()`: ```js 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: ```js // 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` ```html ``` 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: ```bash # 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` ```js // 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 `
``` 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` ```js 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`: ```json { "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 ``. 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 ```bash # 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. ```bash # 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: ```bash # 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 ``. Load full `styles.css` asynchronously: ```html ``` --- ## 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.