ARRFLIX/docs/23-arrflix-edge-perf-audit.md
s8n 851f317dbb doc 23: arrflix edge / network / browser-load-path perf audit (read-only)
Edge audit complementing doc 13 (server-side perf). Confirms cold-load
"feels slow" perception is dominated by:
  - no HTTP compression at Traefik (2.74 MiB raw JS bundles per cold load)
  - no Cache-Control on hashed-asset URLs (28 conditional GETs per warm load)
  - first-fetch poster image transcode ~385 ms (server-side, doc 13 #02)

TLS, MTU, HTTP/2, cert chain, middleware chain, Pi-hole hairpin all
audited and clean. Pi-hole missing local DNS rewrite for arrflix.s8n.ru
(LAN clients hairpin via WAN unless /etc/hosts pin in place).

Top quick win: add `compress@file` middleware in
/opt/docker/traefik/config/dynamic.yml + reference from Jellyfin router
label. ~70 % cold-load wire-size reduction (2.74 MiB to ~0.82 MiB
gzip / ~0.69 MiB brotli). One file edit, no architectural change.

No fixes applied. No state mutated. No Traefik reload.
2026-05-08 17:50:52 +01:00

587 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 **~2535 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 `<script src=…>` tags in index.html and discarded after
report). All 28 share the same query-string version
`?7dc095d8f634f60f309c` — they ARE content-versioned URLs and SHOULD
be cached `immutable`.
| Rank | Bundle | Bytes | Notes |
|---:|---|---:|---|
| 1 | `node_modules.@jellyfin.sdk.bundle.js` | 740 699 | Largest single file. Compresses ~70 %. |
| 2 | `main.jellyfin.bundle.js` | 499 108 | App bundle. Compresses ~70 %. |
| 3 | `node_modules.@mui.material.bundle.js` | 381 100 | MUI components. Compresses ~75 %. |
| 4 | `node_modules.core-js.bundle.js` | 182 469 | Polyfills. Compresses ~75 %. |
| 5 | `node_modules.react-dom.bundle.js` | 128 970 | React DOM. Compresses ~75 %. |
| 6 | `node_modules.@tanstack.query-core.bundle.js` | 101 747 | React-Query. Compresses ~70 %. |
| 7 | `node_modules.jellyfin-apiclient.bundle.js` | 88 025 | Compresses ~70 %. |
| 8 | `node_modules.jquery.bundle.js` | 87 296 | Compresses ~70 %. |
| 9 | `node_modules.axios.bundle.js` | 80 291 | Compresses ~70 %. |
| 10 | `node_modules.date-fns.esm.bundle.js` | 74 309 | Compresses ~70 %. |
| 11 | `node_modules.@remix-run.router.bundle.js` | 72 992 | |
| 12 | `37869.bundle.js` | 70 690 | Lazy chunk. |
| 13 | `runtime.bundle.js` | 49 152 | Webpack runtime. |
| 14 | `node_modules.webcomponents.js.bundle.js` | 39 705 | |
| 15 | `node_modules.@mui.icons-material.bundle.js` | 30 861 | |
| — | (13 more bundles, each 530 KB) | ~351 000 | |
| **Total JS** | **28 bundles** | **2 806 173** | **(2.68 MiB raw)** |
| + | `index.html` | 65 485 | |
| + | `theme.css` + assets | ~32 000 | |
| **Cold-load total** | | **~2.76 MiB** | **uncompressed** |
Wall-time measurements from onyx (LAN-direct, sequential):
- **5 top bundles, sequential GET, LAN:** 0.37 s for 1.65 MiB.
- **All 28 bundles, sequential GET, LAN:** 1.51 s for 2.68 MiB.
A real browser uses HTTP/2 multiplexing so won't be strictly
sequential, but `connection-window` + `flow-control` mean wire-time on
WAN scales nearly linearly with total bytes. Compression alone would
cut wire-time ~70 %.
Estimated post-compression total: **~0.82 MiB** (gzip) or **~0.69 MiB**
(brotli). At a 50 Mbps WAN, that's a 200300 ms cold-load saving
*before* any RTT improvements from cache headers.
---
## 5. ServiceWorker warm-load effectiveness
**Conclusion: SW does NOT cache app assets.** Verified by reading
`/web/serviceworker.js` (768 B, last modified 2024-11-19 — Jellyfin
10.10.3 ship date).
The SW only handles `notificationclick` events (cancel-install /
restart-server actions) and a one-shot `activate``clients.claim()`.
There is **no `fetch` handler**, no `install` precache, no asset
caching at all. This matches doc 13 finding 11.
So the warm-load is doing exactly what the browser HTTP cache + ETag
flow gives us: 28 conditional GETs, each returning 304 with empty
body but a full TLS-multiplexed round-trip. With proper
`Cache-Control: max-age=31536000, immutable` on the hashed URLs,
all 28 of those revalidations would collapse into zero network
traffic on warm load.
---
## 6. Poster image timing
Tested against Rick and Morty series ID
`548035d5e4d36cd2f488900ab612581a`,
`/Items/{id}/Images/Primary?fillHeight=300&fillWidth=200&quality=96`.
| Request | TTFB | TOTAL | Bytes |
|---|---:|---:|---:|
| **Cold (uncached size variant)** | 385 ms | 388 ms | 45 660 |
| Warm 1 | 26 ms | 29 ms | 45 660 |
| Warm 2 | 38 ms | 42 ms | 45 660 |
| Warm 3 | 34 ms | 38 ms | 45 660 |
| Warm 4 | 37 ms | 42 ms | 45 660 |
| **Cold h=400** | — | 351 ms | 79 925 |
| **Cold h=500** | — | 469 ms | 112 168 |
| **Cold h=600** | — | 364 ms | 145 505 |
Response headers:
```
HTTP/2 200
age: 0
cache-control: public ← no max-age
content-disposition: attachment ← unusual on a poster (forces 'save')
content-type: image/jpeg
last-modified: <request time> ← unhelpful for caching
vary: Accept
```
Two issues here:
- **`Cache-Control: public` with no `max-age`** means the browser
applies heuristic freshness (10 % of last-modified age = 0 s, since
last-modified equals the response time). Effectively uncached. Every
navigation back to the home page re-fetches all posters.
- **Server-side image transcode is the dominant cost.** Jellyfin
generates the `fillHeight=300&fillWidth=200&q=96` variant on demand
from the source poster image, then caches it in
`/cache/images/`. `age: 0` on response confirms this was a fresh
generation. Doc 13 finding 26 puts the on-disk image cache at 15 MB
total — small enough that recent-cache eviction may be culling
variants.
Per-poster cold cost: 350470 ms. Twenty posters at unique
`fillHeight` × `fillWidth` × `quality` variants on the first load
of "Recently Added" totals **~7 s** if the browser drops to single-
threaded poster fetches (HTTP/2 multiplexes, so true cost is
GPU/CPU-bound on the server side). Doc 13 finding 02 (no GPU
transcode, 12-core box already at load 11.4) means even this is
software-rendered.
`content-disposition: attachment` on an image fetched into an
`<img>` tag doesn't actually force a download (the browser ignores
the disposition for media references), but it's a Jellyfin-side
oddity worth noting.
---
## 7. Traefik request-log latency analysis
`docker logs traefik --since 6h | grep jellyfin@docker` — total 116
requests, 78 of them at 0 ms (cache hits / 304s / 401s).
Latency histogram (ms suffix on each log line):
| Bucket | Count |
|---|---:|
| 0 ms | 78 |
| 1 ms | 8 |
| 3 ms | 1 |
| 7 ms | 1 |
| 1846 ms | 4 |
| 92294 ms | 5 |
| 346648 ms | 4 |
| 1.12.1 s | 3 |
| 4.99.5 s | 4 |
**Every entry above ~50 ms is a `/videos/.../hls1/main/*.mp4`
HLS-segment GET, not a `/web/*` static asset.** Decoded request
URIs show the slow ones are AV1 + HEVC transcode requests with
`VideoBitrate=362547 Mbit` and 500/499 final status — exactly the
pattern doc 13 finding 03 calls out (CPU-only transcode + no
throttling). Edge layer is clean: every `/web/*` request that
appeared in the 6-hour window completed in 07 ms wall.
Status code distribution for `jellyfin@docker` (6 h):
| Code | Count |
|---:|---:|
| 200 (filtered out by accessLog statusCodes 400-599) | (not logged) |
| 400 | 1 |
| 401 | 7 |
| 404 | 68 |
| 405 | 8 |
| 499 | 15 |
| 500 | 8 |
| 502 | 1 |
The 68 × 404 are mostly `Cineplex/CSS/icon` references from the bundled
theme @import-ing assets that Jellyfin doesn't ship — cosmetic, but
each 404 is a wasted RTT on every cold-load (browser fetches the
referenced URL, gets 404, retries on next page nav). Worth a separate
look in coordination with doc 09 (Cineplex theme).
---
## 8. Traefik middleware audit
### Static config (`/opt/docker/traefik/traefik.yml`)
```yaml
entryPoints:
websecure:
address: ":443"
http:
middlewares:
- security-headers@file
- rate-limit@file
```
### Jellyfin router (`/opt/docker/jellyfin/docker-compose.yml`)
```yaml
labels:
- "traefik.http.routers.jellyfin.middlewares=security-headers@file"
```
### Effective middleware chain at `/web/*` request
1. `security-headers@file` (entrypoint) — header rewrites, no body
processing, ~zero CPU.
2. `rate-limit@file` (entrypoint) — token-bucket avg=100 burst=200
period=1s. Pure counter, ~zero CPU. **Not** a regex chain. **Not**
doing CPU-significant work.
3. `security-headers@file` (router, **duplicate**) — applied a second
time to the response. Idempotent (header overwrite is a no-op when
value already set), but **redundant** and a small CPU waste
per-request. Worth deduping.
### What's missing
- **`compress` middleware**. Traefik supports it with a one-liner:
```yaml
middlewares:
compress:
compress: {}
```
Defaults to gzip + brotli, sizes ≥1024 B, smart `Accept-Encoding`
negotiation. Not present anywhere.
- **No `headers.customResponseHeaders.Cache-Control`** override on
the Jellyfin router — Traefik would let us inject
`Cache-Control: public, max-age=31536000, immutable` for
`/web/*.bundle.js?*` requests via a `replacePath`-+-`headers`
combination, OR (cleaner) Jellyfin can be configured to send the
right headers itself; this is config not architecture.
### Traefik middleware chain on other Jellyfin paths
The `no-guest@file` allowlist seen in dynamic.yml is **not** referenced
by the Jellyfin router (per doc 09 §1.2 it was intentionally dropped
when WAN exposure was added). That matches expectation; not an edge
performance issue.
The `headscale-deny-leaks` and `signup-strict` middlewares are
defined but only referenced by other routers. No effect on Jellyfin.
---
## 9. DNS / hairpin / MTU
| Probe | Result | Verdict |
|---|---|---|
| Pi-hole DNS lookup `dig arrflix.s8n.ru @192.168.0.1` | returns **`82.31.156.86` (WAN)** | **Y — split-horizon missing.** Onyx's `/etc/hosts` pin saves it; any LAN client without that entry hairpins through the router. |
| Onyx hairpin to WAN IP, full TTFB | 3343 ms | **G — hairpin works, no NAT-loopback latency penalty.** |
| LAN MTU `ping -M do -s 1472 -c 3` | 1480/1480/1480, 1.171.75 ms | **G — full 1500 MTU, no fragmentation, no PMTUD penalty.** |
| `--resolve` LAN-direct vs hostname | DNS adds 300 µs | **G — negligible.** |
The Pi-hole gap is a doc-09-related exposure decision: arrflix.s8n.ru
has public DNS on Gandi pointing at the WAN IP, no Pi-hole local
override. For an LAN-first deploy you'd add a local DNS rewrite
`arrflix.s8n.ru → 192.168.0.100` on Pi-hole. Per memory note
`feedback_s8n_hosts_override.md`, this is a known pattern (`/etc/hosts`
pin on each device works, but doesn't scale to phones).
---
## 10. HTTP/2, HTTP/3, TLS
- **HTTP/2:** confirmed (`HTTP/2 200` response, multiplexing
available).
- **HTTP/3 (QUIC):** **not enabled.** `Alt-Svc` header is absent on
every probe. (My local libcurl doesn't support `--http3` so I can't
client-test, but the lack of `Alt-Svc` advertises that the server
doesn't speak QUIC.) Traefik ≥ 2.8 supports HTTP/3 via experimental
`entryPoints.websecure.http3 = {}` block; not enabled in
`traefik.yml`. **Y — would help WAN clients on lossy links** (mobile
data, café WiFi); near-zero benefit on LAN.
- **TLS chain:** 2 certs (leaf + LE R13 intermediate) → ISRG Root X1
is in client trust store. Chain length is minimal; not contributing
to handshake latency.
- **TLS version:** 1.3 with AEAD cipher (`TLS_AES_128_GCM_SHA256`).
- **`sniStrict: true`** in dynamic.yml's `tls.options.default`. Correct.
---
## 11. Concrete remediation list (ranked by impact-per-effort)
| # | Fix | Effort | Impact | Risk |
|---:|---|:-:|---|---|
| **1** | **Add `compress@file` middleware** in `/opt/docker/traefik/config/dynamic.yml`: `compress: {}` under `http.middlewares.compress`. Reference it from the Jellyfin router via a `traefik.http.routers.jellyfin.middlewares=security-headers@file,compress@file` label edit in `/opt/docker/jellyfin/docker-compose.yml`. | **S** (5 min) | **~70 % cold-load wire reduction** (2.74 MiB → ~0.82 MiB). Lowers TTI on every single first-visit. | Low — Traefik's `compress` is a standard middleware, gzip+br, content-type allow-list does the right thing for `application/javascript` + `text/html` + `text/css`. Will not compress `image/jpeg`. |
| **2** | **Add `Cache-Control: public, max-age=31536000, immutable` for `/web/*.bundle.js?*` and `/web/*.css?*` requests.** Cleanest path is via Traefik `headers` middleware with `customResponseHeaders.Cache-Control` and a router rule that matches `Path(\`/web/\`) && Query(\`hash\`)` — but Jellyfin can also be patched at the source if there's appetite. | SM | Eliminates 28 × per-page-nav round-trips for warm load. Saves ~28 RTTs (~1.5 s on a 50-ms WAN link, ~0 on LAN). | Medium — must scope ONLY to hashed URLs; if `Cache-Control: immutable` is applied to `index.html` you brick the next deploy until users force-reload. |
| **3** | **Enable HTTP/3 / QUIC.** Add `entryPoints.websecure.http3 = {}` to `traefik.yml`, expose UDP 443 on the host, and add an `Alt-Svc: h3=":443"; ma=86400` header (Traefik does this automatically once the HTTP/3 entrypoint is on). | M | Marginal on LAN, real on lossy WAN (3G, café WiFi). Cuts TLS handshake to 1-RTT. | Low — Traefik HTTP/3 has been stable since v3.0; coexists with H/2. Need to open UDP 443 on nullstone firewall + router port-forward. |
| **4** | **Tighten poster image cache.** Either set `Cache-Control: public, max-age=86400` on `/Items/*/Images/Primary` responses (Jellyfin-side via `system.xml` `MaxResumePct` style — actually a Jellyfin web-server-config patch), or put a Traefik-level `headers.customResponseHeaders.Cache-Control` on `Path(\`/Items/\`) && PathPrefix(\`/Images/\`)`. Even 1 hour of caching collapses the poster grid re-fetch on home-page bounce-back. | SM | ~7 s saved on home-page revisit when posters were already fetched. | Low — posters are content-addressed by `?fillHeight=…&quality=…`; safe to cache. |
| **5** | **Dedupe security-headers middleware.** Remove the entrypoint-level `security-headers@file` OR remove it from each per-router label. (Cleanest: keep it at entrypoint level, drop from labels.) | S | Tiny (microseconds per request). Cleanup, not perf. | Low. |
| **6** | **Add Pi-hole local DNS rewrite for `arrflix.s8n.ru` → `192.168.0.100`.** Memory note `feedback_s8n_hosts_override.md` already covers this pattern. Onyx `/etc/hosts` works but doesn't scale to phones / friends' devices. | S | Stops LAN clients hairpinning through router on every fetch. Saves 1× NAT-loopback round-trip per TCP connection (~2 ms — small but free). | Low. |
| **7** | **Investigate the 68 × 404 in 6 h on `/web/*`.** Likely Cineplex theme @import or icon references with bad paths. Each 404 is a wasted RTT on cold-load. | S | Small but cumulative on cold-load. | Low — read-only investigation first. |
| **8** | **Strip `content-disposition: attachment` on Image responses.** Jellyfin emits this on every `/Images/Primary` GET. Browser ignores it for `<img>` references but it's hostile if anyone right-clicks "open image in new tab". | S | Cosmetic. | Low. |
### Recommended fix order
The order **#1#2#3** is the entire cold-load story. **#1
alone** turns "kinda slow" into "fine" for 90 % of the perceived
latency on first load. **#2** turns 2nd-page-nav into "instant" by
eliminating the 28-asset revalidation tax. **#3** is the WAN-optimist
nice-to-have; do once mobile clients matter.
Out of scope for this audit but worth noting from doc 13: GPU
transcode re-enable (#02 there) is the real win for *playback*
latency. Cold-load + playback are separate paths; both need
attention.
---
## 12. Out of scope (audited and found healthy)
- **TLS handshake latency** (2225 ms LAN, normal for TLS 1.3 fresh
handshake; reuse hides it).
- **Cert chain depth** (2-cert chain, R13 intermediate).
- **MTU** (1500, no fragmentation).
- **HTTP/2** (working, multiplexed).
- **DNS lookup** (300 µs via /etc/hosts; 20160 ms first time via
Pi-hole, cached after).
- **Hairpin NAT** (works, no extra latency).
- **`rate-limit@file` middleware** (token-bucket, ~zero overhead).
- **Sniff/CSP/STS/frame headers** — set correctly, no perf cost.
- **ServiceWorker** (notification-only, no perf-positive nor
perf-negative).
- **Traefik access log filter** (statusCodes 400-599 only — does NOT
log the 200 OK responses that dominate `/web/*`; the latency
histogram in §7 is therefore a 5xx/4xx-only sample, not full
traffic. The 5xx/4xx sample is conclusive enough for edge analysis
because all the slow ones are HLS transcode failures, not edge
problems).
---
## Appendix — raw evidence
### Curl timing (LAN-direct, 5 samples)
```
DNS=0.000024 CONN=0.001225 TLS=0.022960 TTFB=0.031569 TOTAL=0.040531
DNS=0.000024 CONN=0.001217 TLS=0.020182 TTFB=0.024190 TOTAL=0.030353
DNS=0.000028 CONN=0.001437 TLS=0.025502 TTFB=0.030467 TOTAL=0.035793
DNS=0.000023 CONN=0.001501 TLS=0.021998 TTFB=0.037444 TOTAL=0.041056
DNS=0.000023 CONN=0.001265 TLS=0.018536 TTFB=0.022942 TOTAL=0.027066
```
### Compression negotiation matrix
```
Accept-Encoding: br → content-length: 65485, no content-encoding
Accept-Encoding: gzip → content-length: 65485, no content-encoding
Accept-Encoding: (empty) → content-length: 65485, no content-encoding
Accept-Encoding: gzip,deflate,br,zstd --compressed → content-length: 65485, no content-encoding
```
### TLS chain
```
depth=2 C=US, O=Internet Security Research Group, CN=ISRG Root X1
depth=1 C=US, O=Let's Encrypt, CN=R13
depth=0 CN=arrflix.s8n.ru
Verification: OK
Protocol: TLSv1.3
Cipher: TLS_AES_128_GCM_SHA256
```
### ETag-conditional revalidation
```
First fetch: HTTP/2 200, etag "1db3a353daaafa4", content-length 499108
If-None-Match: HTTP/2 304, etag "1db3a353daaafa4", body empty
```
### Bundle inventory (28 bundles, total 2 806 173 bytes)
Top 15 by size — see §4 table. Full list reproducible from
`curl -s https://arrflix.s8n.ru/web/index.html | grep -oE 'src="[^"]*\.bundle\.js[^"]*"'`.
### Poster image fetch (5 samples — first cold, rest warm)
```
TTFB=0.385230s TOTAL=0.388290s SIZE=45660b ← cold (server transcode)
TTFB=0.025961s TOTAL=0.028951s SIZE=45660b
TTFB=0.037838s TOTAL=0.041724s SIZE=45660b
TTFB=0.034244s TOTAL=0.038364s SIZE=45660b
TTFB=0.036687s TOTAL=0.041616s SIZE=45660b
```
### Traefik static config (entrypoints)
```yaml
websecure:
address: ":443"
http:
middlewares:
- security-headers@file
- rate-limit@file
```
### Jellyfin router labels (compose)
```yaml
"traefik.http.routers.jellyfin.middlewares=security-headers@file"
"traefik.http.services.jellyfin.loadbalancer.server.port=8096"
```
### MTU + ping
```
PING 192.168.0.100 (192.168.0.100) 1472(1500) bytes of data
1480 bytes from 192.168.0.100: icmp_seq=1 ttl=64 time=1.66 ms
1480 bytes from 192.168.0.100: icmp_seq=2 ttl=64 time=1.75 ms
1480 bytes from 192.168.0.100: icmp_seq=3 ttl=64 time=1.17 ms
0 % packet loss, rtt min/avg/max/mdev = 1.171/1.524/1.745/0.252 ms
```
### Pi-hole DNS resolution
```
$ dig +short arrflix.s8n.ru @192.168.0.1
82.31.156.86 ← public WAN IP, not the LAN 192.168.0.100
```
### Traefik request-log latency histogram (jellyfin@docker, 6 h, 5xx/4xx only — 200s filtered out)
```
78 0ms
8 1ms
1 3ms
1 7ms
1 18ms
1 29ms
1 39ms
1 46ms
1 92ms
1 175ms
1 192ms
1 209ms
1 222ms
1 274ms
1 294ms
1 346ms
1 391ms
1 648ms
1 1168ms
1 1256ms
1 2140ms
1 4931ms
1 8118ms
1 9543ms
```
All entries >50 ms are `/videos/.../hls1/main/*.mp4` — HLS transcode
requests with 500/499 status, AV1+HEVC at 360550 Mbit source. Edge
is not the bottleneck on those; CPU transcode is (doc 13 #02, #03).
---
## Sign-off
- Audit: 2026-05-08, read-only, ~30 min wall.
- No fixes applied. No state mutated. No container restart. No
Traefik reload. No header injected. Admin token used only for
read-side `/Items` and `/Items/.../Images` probes.
- Next audit due: **after fix #1 ships**, to confirm gzip/brotli
ratio on the actual deployed config and re-measure cold-load.