OpenVibe.Live/Docs
Source on GitHub

Performance audit — 2026-09-16/17#

Starting point: HEAD 808262d ("Load pastes.js and dashboard.js on demand…"), branch seadragon, clean worktree. Everything below was measured, not estimated; where a number could not be measured it says so.

How it was measured#

ToolWhat it does
scripts/perf/measure-page.js <url> [--runs N] [--phone] [--phone-cpu]cold load in headless Chrome over the DevTools protocol, fresh profile and HTTP cache disabled per run; reports medians of transfer, requests, DOM nodes, running animations, FCP/LCP/CLS, long tasks, TBT, script/style/layout time, the LCP element (lcpEl) and the element behind the largest layout shift (clsEl)
real users (public/js/ov-rum.js → server/web/rum.js)every page load (not automated browsers) reports its own TTFB/FCP/LCP/CLS/INP/blocking time and script errors; aggregates per page kind × device class at GET /api/admin/rum (staff) and live_rum_p75 / live_rum_errors on /metrics
scripts/perf/ab.js <urlA> <urlB> --runs Nthe same, interleaved A,B,A,B… so machine load affects both sides equally
scripts/perf/trace-page.js <url> [--tasks]Chrome trace; attributes style/layout work to the JavaScript that forced it; --tasks lists each main-thread task over 100 ms with what ran inside it
test/browser/smoke.jsroutes × widths, errors, overflow, navigation, duplicate scripts, resource growth over repeated navigation

Profiles: desktop 1366×900 unthrottled; phone 412×900, 4× CPU, 1.6 Mbps/150 ms; phone-cpu 412×900, 4× CPU, network unthrottled.

Caveats that matter:

Baseline — production, https://openvibe.live/, 3 cold runs (median)#

MetricDesktopPhone (4× CPU, 1.6 Mbps)
HTML381 KB raw / 75.5 KB wiresame
Scripts44 files, 1 993 KB raw / 612 KB wire49 files, 2 677 KB raw / 784 KB wire (idle prefetch ran)
Stylesheets12 files, 1 029 KB raw / 237 KB wiresame
Requests10191
DOM nodes7 4487 160
Running animations5320
FCP1 620 ms6 344 ms
LCP5 792 ms9 808 ms
CLS0.0860.133
TBT807 ms2 589 ms
Style / layout589 / 1 085 ms1 807 / 2 012 ms

Results — local A/B, home page, old build vs new build#

Desktop, 7 interleaved runs each#

MetricBeforeAfterChange
HTML (raw)374.8 KB94.9 KB−74.7%
Script files4323−46.5%
Script bytes (raw)1 965 KB1 000 KB−49.1%
CSS bytes (raw)1 029 KB824 KB−19.9%
Requests7755−28.6%
Total transfer3 743 KB2 252 KB−39.8%
DOM nodes6 4021 741−72.8%
LCP5 644 ms2 552 ms−54.8%
FCP2 668 ms2 552 ms−4.3% (noise range)
Script time428 ms338 ms−21.0%
Layout time1 659 ms1 487 ms−10.4%
CLS0.0620.064unchanged

Phone CPU profile, 9 interleaved runs each (final build, self-hosted icons)#

MetricBeforeAfterChange
HTML (raw)374.8 KB94.7 KB−74.7%
Script bytes (raw)1 965 KB1 000 KB−49.1%
CSS bytes (raw)1 029 KB824 KB−19.9%
Total transfer3 743 KB2 347 KB−37.3%
DOM nodes6 3421 732−72.7%
Running animations3021−30.0%
FCP3 260 ms2 792 ms−14.4%
LCP3 412 ms3 024 ms−11.4%
TBT4 565 ms3 846 ms−15.8%
Script time618 ms336 ms−45.6%
Style time2 061 ms1 858 ms−9.8%
Layout time2 717 ms2 667 msunchanged

What did not improve: layout on the home page is dominated by the page's own content (hero, collage, rails) and stayed flat; long-task totals are within noise.

What changed, and why it moved these numbers#

ChangeEffect
Route markup moved to public/fragments/*.html, inlined by the server only for the route requestedindex.html 382 KB → 81 KB source; DOM nodes −73%
public/features.json + js/ov-loader.js: 28 route scripts load with their route (and are prefetched on link hover/focus, download-only)home scripts 44 → 23 files
app.js split by reachability analysis into core (128 KB) + app-home, app-channel, app-media, app-chatpage, app-docs539 KB → 128 KB always-loaded
Stylesheet split by class ownership with cascade-order checks (computed styles diffed on 44 route states: only data-driven differences remain)style.css 666 KB → 484 KB; broadcast.css 100 KB → 51 KB; 8 feature files
A stray } in style.css made browsers drop the whole @media (max-width:1200px) blockfixed: nav links no longer overflow at 1100 px (79 px → 0)
Forced synchronous layouts found with trace-page.js: ov-density measured inside render (≈0.5 s at 4× CPU), odometer reflow per digit, tour diagram wired below the fold, innerWidth reads during loadmeasured in ResizeObserver, batched, wired on visibility, matchMedia
Hero quip and first rotating word shipped in the HTMLLCP element paints at first render instead of after /api/home/hero
Font Awesome self-hosted (woff2 only, content-hashed URLs)no third-party connection on the critical path
Dead theme-engine.js (parser-blocking) removedone fewer blocking script

Server#

MetricBeforeAfterHow measured
Graceful shutdown (SIGTERM → exit)forced exit after ~5.3 s with code 1 (SSE clients kept server.close() open; per audit)1.1 s, exit 0local, kill + timing
Local boot to /api/ready~2.5 s~2–3 srestart script (unchanged within noise)
Event-loop delaynot observablep50/p99/max per minute at GET /api/admin/diagnostics, logged when p99 ≥ 200 msserver/diagnostics.js
Static response for style.css (8 concurrent, Node)67 ms mediancached in nginx after first fetch (X-Cache HIT verified with nginx 1.27)local bench + docker nginx

Deploy#

Change typeBeforeAfter (release layout)
docs / public onlyrestart (legacy script restarted on every commit)no restart; release layout switches current, same PID
server coderestartrestart, readiness-gated, automatic rollback
lockfilenpm ci in place; rollback kept new node_modulesinstalled into the new release; rollback returns the old release's node_modules

Measurement 2026-09-26: after the release-client work (roadmap WS-T task 2)#

Shared 1.17 to 1.20 changed release-watch.js, which every page loads after first paint. It gained release notifications (one EventSource), session beats every 5 minutes, the prompted count and client generations, and stayed under its 5 KB brotli budget (4.94 KB). Production https://openvibe.live/, desktop 1366×900 unthrottled, node scripts/perf/measure-page.js https://openvibe.live/ --runs 3 --settle 8000, two runs of three, medians:

MetricBaseline (above)2026-09-26
HTML381 KB raw / 75.5 KB wire92.5 KB raw / 23.6 KB wire
Scripts44 files, 612 KB wire33 files, 438 KB wire
Stylesheets12 files, 237 KB wire11 files, 194 KB wire
Requests101103–104 (the release-watch stream and its manifest read)
DOM nodes7 4482 969
Running animations5338
FCP / LCP1 620 / 5 792 ms884–908 / 884–908 ms
CLS0.0860.073–0.083
TBT807 ms217–259 ms
Style / layout589 / 1 085 ms595–598 / 554–589 ms

The release client adds one request (the EventSource, which excludes itself from idle-network checks) and no measurable paint or blocking cost. The size budgets in npm test (scripts/perf/check-budgets.js) still pass.

Measurement 2026-10-12: the first-paint stylesheet#

A phone-profile sweep of every OpenVibe home page (4x CPU, ~1.6 Mbit/s) put openvibe.live last: first paint 4.9–5.3 s. Every route blocked on all of style.css (468 KB raw, ~97 KB brotli as served), while CDP rule coverage over the public routes (home, content, VODs, clips, moments, arena, chat, search, a channel, a VOD, a clip, pastes) at 412 and 1366 px used 436 of its selectors, 17–18 % of the file.

Measurement 2026-10-12: layout shift on direct visits#

With the first paint earlier, what moved afterwards showed: on a phone a direct visit to a channel scored CLS 1.31, a clip 0.99, a VOD 0.43 and the home page 0.13 (Chrome's "good" line is 0.1). Each had the same shape: the shell painted placeholder markup, then the data, and then several parts that load on their own, each pushing the page down.

Same A/B harness (phone profile, worst of two runs): channel 1.31 → 0.012, clip 0.99 → 0.025, VOD 0.43 → 0.004, home 0.127 → 0.011; at 1366 px channel 0.09 → 0.015, VOD 0.19 → 0.001, home 0.06 → 0.01. First paint unchanged. test/first-paint-stability.test.js holds the pieces in place.

Measurement 2026-10-17: largest paints, the channel page's blocking time, real users#

Phone CPU profile (4× CPU, network unthrottled), production, median of 3:

PageBeforeAfterWhat it was
home: LCP2.94 s1.15 sthe hero's AI-moment background (div.hero-bg-layer) was set by app-home.js after /api/home/hero; the server now preloads it and paints it on the first layer (#206)
/@goosely (offline): TBT3.7 s1.2 schat.js re-rendered the whole scrollback (500 channel + 500 global rows) in one idle callback, 2.7 s; now the newest 50 rows, then 10-row pieces per idle deadline (#207, #209, #210)
/@goosely: LCP3.3 s2.45 sthe offline screen's "#1 VOD" picture was lazy and waited for /popular after the channel request; now eager, high priority, fetched with the channel (#216)
openvibe.video watch page: LCP3.8 s0.88 sthe <video> had no poster, so the largest paint waited for the first frame (MediaHub#94)
/@goosely: DOM after load~1000 chat rows~200 rowsolder scrollback loads when the reader scrolls up to it (a sentinel and an IntersectionObserver), so every later message lays out a smaller page (#224)
/@goosely: CLS0.120the offline header showed outlines, then the social links (135 px on a phone) pushed it and the screen down; header and screen now stay invisible until the data (#225)
/chat (phone): CLS0.040.024the voice channel list filled in above the chat; one row is held for it (#226)

Server side (host, warm): /api/streams/channel/:username 120–150 → 95–120 ms (clipper names and AI overviews in one query each instead of one per row, #213); /api/vods/:id and /api/clips/:id now run their six lookups in parallel (23–40 ms, #214). Other OpenVibe sites' homes and inner pages: FCP/LCP 0.5–0.9 s, TBT under 210 ms, no overflow at 320 px.

The throttled-network --phone profile still shows the home's first paint at 3.3 s (six render-blocking stylesheets share the 1.6 Mbps link with ~30 deferred scripts). Real-user data (#217) is the arbiter now: compare live_rum_p75{page="home",device="phone"} before restructuring the stylesheets.

Budgets#

npm run perf:budget (scripts/perf/check-budgets.js) fails if the home page's raw HTML, eagerly loaded JS or render-blocking CSS grow past the limits recorded there.

This page is rendered from docs/performance-audit.md in the OpenVibe.Live repository. Found a mistake? Edit it there.