2026-02-18 11:15:15 +01:00
# Performance Audit: First Page Load
**Date** : 2026-02-18
2026-02-26 16:44:34 +01:00
**Scope** : Browser first-load of Codeman web UI (`/` )
2026-02-18 11:15:15 +01:00
**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]
│
2026-02-26 16:44:34 +01:00
├── JS executes: new CodemanApp().init()
2026-02-18 11:15:15 +01:00
│ ├── 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`
```html
<!-- 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:
```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
<!-- 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:
```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 `<style>` + skeleton HTML in `<body>` showing a dark header bar + terminal placeholder. Hidden by `app.js` once init completes:
```html
< style >
. skeleton { display : flex ; flex-direction : column ; height : 100 vh ; }
. skeleton-header { height : 40 px ; background : #111 ; border-bottom : 1 px 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`
```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 `<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
```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 `<head>` . Load full `styles.css` asynchronously:
```html
< 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.