diff --git a/docs/23-arrflix-edge-perf-audit.md b/docs/23-arrflix-edge-perf-audit.md new file mode 100644 index 0000000..18640d6 --- /dev/null +++ b/docs/23-arrflix-edge-perf-audit.md @@ -0,0 +1,587 @@ +# 23 — ARRFLIX Edge / Network / Browser-Load-Path Audit + +> Status: **read-only audit**, executed 2026-05-08 from onyx +> (192.168.0.6 LAN) against `https://arrflix.s8n.ru` (Jellyfin 10.10.3 +> behind Traefik on nullstone). Scope: edge — DNS, TLS, Traefik, +> compression, cache headers, asset waterfall, ServiceWorker. **No +> fixes applied. No state mutated. No container restart. No Traefik +> reload.** +> +> Sibling audits cover color/HDR, server runtime, and storage. This one +> is the edge slice only. Pairs with doc 13 (server-side optimization +> audit, 2026-05-08) — that one calls out CPU/transcode; this one +> identifies why every page-nav over WAN feels gluey when the server is +> idle. + +--- + +## 1. Executive summary + +The two cold-load complaints ("loads kinda slow") are dominated by a +single edge defect with three symptoms: + +1. **No HTTP compression at all.** Traefik has zero `compress` + middleware defined in either static (`traefik.yml`) or dynamic + (`config/dynamic.yml`) config, and the Jellyfin router only attaches + `security-headers@file`. Result: Jellyfin's 28 webpack JS bundles + ship raw — **2.74 MiB of JS over the wire on every cold load**. + With gzip (default ratio ~0.30 for minified JS) that drops to + ~0.82 MiB. With brotli (~0.25) it drops to ~0.69 MiB. **Severity: + R — fix this week.** +2. **No `Cache-Control` on hashed-asset URLs.** Every JS bundle + comes back with `etag` + `last-modified` and **no** `cache-control` + header. Browsers default to "heuristic freshness" (typically 10 % of + `last-modified` age) but every reload **does still issue a + conditional `If-None-Match` request per asset** and gets a 304 + back. On a cold-cache page-nav that's **28 round-trips of pure + negotiation overhead** even when the response body is cached. + These URLs are content-hashed (`?7dc095d8…`), so they should be + `Cache-Control: public, max-age=31536000, immutable`. **Severity: + Y → R when WAN clients are involved (each round-trip costs an + internet RTT).** +3. **Poster image first-fetch is slow** — the very first cold request + for a `/Items/{id}/Images/Primary` triggers a Jellyfin server-side + image transcode (resize + JPEG re-encode) and runs in **~385 ms + wall** vs **~25–35 ms** for warm cache. With ~20 posters on the home + page and no edge cache, the first visit to "Recently Added" is a + **~7-second poster grid**. Doc 13 finding 23 (3 MB splash PNG) is + the loud single hit; this is the death-by-a-thousand-cuts equivalent + for the home page. **Severity: Y.** + +Everything else (TLS handshake, MTU, DNS lookup, HTTP/2 vs HTTP/3, +cert chain depth, Traefik middleware chain, Pi-hole hairpin) is +healthy or low-impact — full table below. + +**Top quick win:** add a `compress@file` middleware in +`/opt/docker/traefik/config/dynamic.yml` and reference it from the +Jellyfin router. **One file edit. Two lines of YAML in the middleware +block, one line on the router. ~70 % cold-load wire-size reduction.** + +--- + +## 2. Curl timing breakdown (5 samples, p50, p95) + +Test: `curl https://arrflix.s8n.ru/web/index.html` from onyx. + +### LAN-direct (`--resolve` to 192.168.0.100) + +| Sample | DNS | CONN | TLS | TTFB | TOTAL | +|--------|-----|------|-----|------|-------| +| 1 | 0.000024 | 0.001225 | 0.022960 | 0.031569 | 0.040531 | +| 2 | 0.000024 | 0.001217 | 0.020182 | 0.024190 | 0.030353 | +| 3 | 0.000028 | 0.001437 | 0.025502 | 0.030467 | 0.035793 | +| 4 | 0.000023 | 0.001501 | 0.021998 | 0.037444 | 0.041056 | +| 5 | 0.000023 | 0.001265 | 0.018536 | 0.022942 | 0.027066 | +| **p50** | **24 µs** | **1.27 ms** | **22.0 ms** | **30.5 ms** | **35.8 ms** | +| **p95** | **28 µs** | **1.50 ms** | **25.5 ms** | **37.4 ms** | **41.1 ms** | + +### Hostname (onyx /etc/hosts → 192.168.0.100) + +| p50 | DNS 0.34 ms | CONN 1.6 ms | TLS 23.0 ms | TTFB 27.5 ms | TOTAL 33.7 ms | + +### Notes + +- DNS via `/etc/hosts` adds ~300 µs vs `--resolve`. Negligible. +- TLS handshake is the dominant cost (≥60 % of TTFB). TLS 1.3 with + `TLS_AES_128_GCM_SHA256`, **2-cert chain depth** (Let's Encrypt R13 + → ISRG Root X1), no avoidable latency there. Connection reuse will + hide it on subsequent requests within the same browser session. +- **TTFB ≤ 40 ms even on cold connection** — server-side latency for + the index.html body itself is fine. The "feels slow" perception is + **not** in this number; it's in the 28-bundle waterfall after + index.html. + +--- + +## 3. Compression / cache header table + +Probed with `Accept-Encoding: gzip, br, zstd`. Every asset was +served raw. + +| Asset | Type | Bytes | Encoding | Cache-Control | ETag | +|-------|------|------:|----------|---------------|------| +| `/web/index.html` | text/html | 65 485 | **none** | **(none)** | yes | +| `/web/runtime.bundle.js?…` | text/js | 49 152 | **none** | **(none)** | yes | +| `/web/main.jellyfin.bundle.js?…` | text/js | 499 108 | **none** | **(none)** | yes | +| `/web/node_modules.@jellyfin.sdk.bundle.js?…` | text/js | 740 699 | **none** | **(none)** | yes | +| `/web/node_modules.@mui.material.bundle.js?…` | text/js | 381 100 | **none** | **(none)** | yes | +| `/web/node_modules.core-js.bundle.js?…` | text/js | 182 469 | **none** | **(none)** | yes | +| `/web/node_modules.react-dom.bundle.js?…` | text/js | 128 970 | **none** | **(none)** | yes | +| `/web/node_modules.@tanstack.query-core.bundle.js?…` | text/js | 101 747 | **none** | **(none)** | yes | +| `/web/node_modules.lodash-es.bundle.js?…` | text/js | 24 604 | **none** | **(none)** | yes | +| `/web/themes/dark/theme.css` | text/css | 8 631 | **none** | **(none)** | yes | +| `/web/manifest.json` | json | 781 | **none** | **(none)** | yes | +| `/web/serviceworker.js` | text/js | 768 | **none** | **(none)** | yes | +| `/web/favicon.ico` | image/x-icon | 6 830 | **none** | **(none)** | yes | +| `/web/touchicon.png` | image/png | 8 515 | **none** | **(none)** | yes | +| `/Items/.../Images/Primary` (cold) | image/jpeg | ~46 000 | **none** | `public` (no max-age) | — | + +Verification — index.html negotiated against four different +`Accept-Encoding` headers. All four returned `content-length: 65485` +and **no** `content-encoding` — confirms Traefik isn't selectively +disabling compression by `User-Agent`/path; the middleware simply +isn't in the chain. + +ETag-revalidation works correctly: a follow-up +`If-None-Match: "1db3a353daaafa4"` returns `HTTP/2 304` immediately — +so warm-load is "fast" only because nothing has changed since cold +load. The browser still pays a round-trip per asset. + +--- + +## 4. Asset cold-load waterfall (top by size) + +`/web/index.html` references **28 webpack-emitted JS bundles** (full +list at `/tmp/edge-audit/bundles.txt` during audit; file generated by +parsing `