# Method — "How heavy are three.js showcase websites?" Study folder: `/home/ubuntu/apps/threejs-study/` (The Bailey Perspective, October 2026). Everything here is reproducible from the scripts in this folder; the numbers in the article come from `data/summary.json` / `data/sites.csv`, which are generated, never hand-edited. ## 1. Research question How many bytes, requests and seconds does a typical website that was proudly posted to the three.js forum "Showcase" category cost a mobile visitor on first load? ## 2. Sample (collect.py) * **Source.** The three.js forum Showcase category via the public Discourse JSON API (`https://discourse.threejs.org/c/showcase/7.json?page=N`) in Discourse's default "latest" order — **most recently active topics first** (a 2018 topic that got a reply last week appears before a quiet 2025 one), then the JSON of each topic (`/t/.json`). The 2026-10-23 run scanned 9 pages = 269 topics created between 2018-09 and 2026-10; the exact list with dates is `data/topics.json`. Only the **first post** of each topic is used: its `cooked` HTML links, Discourse `link_counts` (external, non-reflection) and the topic's `featured_link`. Paging stopped once at least 220 distinct candidate domains existed and at least 150 of them passed the pre-check (otherwise it continues, page cap 40). The raw forum JSON is cached in `data/cache/` so re-runs do not hit the forum again. * **What counts as a "project website".** Links are dropped when the host is a code host, sandbox, social network, video site, chat, image host, package CDN, the three.js site/forum itself, itch.io, Google Drive/Docs, app stores, Patreon/Ko-fi, blogging platforms, Wikipedia, or when the URL is a file (.zip .pdf .png .jpg .gif .mp4 ...). The list from the brief is `exclude_hosts_spec`; additions in the same spirit (award galleries, tutorial and documentation sites, data/API providers, frameworks, engines and hosting-platform homepages that posts *credit* rather than *present*, file sharing, URL shorteners) are `exclude_hosts_extra`. Both lists, plus the file-extension list, are written verbatim into `data/collect_meta.json` with every run. Every single link seen and the decision taken for it is in `data/links.json`. * **One URL per registrable domain.** Candidates are deduped by registrable domain so one prolific author or one agency does not dominate. Among the URLs posted for a domain the shortest / most homepage-like one is kept (fewest path segments, no query string, shorter, https). The registrable domain is computed with a *heuristic subset* of the Public Suffix List (stdlib has none): hosting platforms where every subdomain is a separate site (`*.github.io`, `*.netlify.app`, `*.vercel.app`, `*.pages.dev`, ...) and common ccTLD second levels (`co.uk`, `com.au`, ...) count as suffixes; everything else uses the last two labels. The list is in `data/collect_meta.json`. * **Pre-check.** Each candidate URL got a polite `HEAD` (then `GET` without reading the body if HEAD was not a clean 200), following redirects, 20 s timeout, one retry with >= 5 s backoff on 429/5xx. Kept = HTTP 200 **and** `Content-Type: text/html` **and** the final URL is not on an excluded host. After redirects the sample was deduped again by final domain. Everything, including dropped candidates and the reason, is in `data/candidates.json`; the kept set is `data/sites.json` (fields: domain, url, final_url, status, server, content_length, topic URL/title/date for attribution). * **Excluded by design.** `thebaileyperspective.com` and its subdomains (that is us). Our two pages are audited with the identical setup into `results-self/` for comparison only and are never part of the statistics. * **Manual review.** `data/manual_exclusions.json` may list domains removed after a human looked at the sample (e.g. a credited tool homepage that slipped through). `summarize.py` drops them from every statistic and lists them in `summary.json` so the article can state them. ## 3. Measurement (audit.sh) * **Tool.** Lighthouse **12.8.2** (`npx -y lighthouse@12.8.2`), default **mobile** config: Moto G Power-class emulation, `simulate` throttling (RTT 150 ms, 1.6 Mbps, 4x CPU slowdown), `--only-categories=performance`, JSON output. One site at a time, 3 s pause between sites, hard wall of 300 s per attempt. * **Browser.** Chromium 141.0.7390.37 (Playwright build 1194) headless on an ARM64 (`aarch64`) Oracle Cloud server, 4 vCPU, with **software WebGL**: `--headless=new --no-sandbox --disable-gpu --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader --disable-dev-shm-usage`. * **Retry rule.** If attempt 1 ends in a Lighthouse runtime error (typically `PAGE_HUNG`: the page's main thread could not answer a 1 s ping when the 45 s load window closed), attempt 2 runs with `--max-wait-for-load=90000`. Which attempt produced the result is in `results/_attempts.json` and in the report (`configSettings.maxWaitForLoad`). Sites that fail both attempts are in `results/_failed.json` with the reason and count as *not measurable in this lab*, which the article must report as a number, not hide. * **One run per site, on one date.** Lighthouse scores vary run to run (typically a few points, more on heavy pages). Nothing was repeated or averaged; `fetch_time` is in every result. Sites change; the dataset is a snapshot. ## 4. What the lab does and does not represent * **Representative:** transfer bytes, bytes by resource type, request counts, largest request, and the network-dominated timings FCP and LCP (simulated throttling computes them from the network dependency graph, not from wall-clock rendering speed). * **Overstated:** anything main-thread: **Total Blocking Time, Time to Interactive, Speed Index, main-thread work, JS bootup** — SwiftShader rasterises WebGL on the CPU, so a scene that renders at 60 fps on a phone GPU may take seconds per frame here. Because TBT (30 %) and Speed Index (10 %) are part of the **performance score**, the score is biased *down* for WebGL-heavy pages in this lab. Treat score and TBT as "lab, software WebGL" numbers and say so when quoting them. * **Lower bounds:** when a page kept loading past the load window, Lighthouse flags "The page loaded too slowly to finish within the time limit" (`run_warnings` column) — bytes/requests for that site are what had arrived by then. Lighthouse measures the initial navigation only: nothing a user triggers later (scrolling, clicking, streaming more assets) is counted. Mobile emulation means sites that serve lighter mobile assets are measured in that lighter form. * **Survivorship / crawler bias:** dead links, sites that answered non-200 to our pre-check (bot protection, HEAD-hostile servers, broken TLS) and sites that hung in the software WebGL lab are excluded or marked failed. All counts are in `candidates.json` / `_failed.json` and should be reported. * **Self-selected sample.** These are projects people chose to show off on the forum — the few hundred most recently active showcase topics; it is not a random sample of three.js sites, and it skews toward demos, games and portfolios rather than corporate production sites. Recently bumped old topics are included, so a few sites are years old. ## 5. Metric definitions (summarize.py) * **Bytes** are Lighthouse `transferSize` (compressed, on the wire) summed over `network-requests`; `lh_total_byte_weight` is Lighthouse's own total for cross-checking; `total_resource_bytes` is the uncompressed size. **MB = 1,000,000 bytes.** * **Resource buckets** (exclusive): document, js (Script or JavaScript MIME), css, image (Image or `image/*` MIME — three.js textures fetched via `fetch()` count), font, media, other. **Cross-cuts** (may overlap the buckets): `wasm_bytes` (`.wasm` or wasm MIME) and **3D asset bytes** = URLs ending in `.glb .gltf .bin .hdr .ktx2 .basis .draco .obj .fbx .exr` (the brief) plus `.ply .stl .splat .spz .ksplat .ktx .dds .pvr .vrm .usdz .dae .3ds .3dm .pcd .vox .vtk .nrrd .mtl .gcode .ifc`, or any URL containing `draco` (so the Draco decoder JS/WASM is counted in both js/wasm and 3D). * **three.js detected** is URL-based only: `yes` if any request URL matches `three(.module)?(.min)?.js | three@ | three/build | /three.` or an unambiguous ecosystem file (`@react-three/`, `three-stdlib`, `three/examples/jsm/...`, `OrbitControls.js`, `GLTFLoader.js`, ...); otherwise `unknown`. Bundled apps hide three.js, so **unknown is not "no"**. `three_evidence` holds the matching URL. * **Percentiles** use linear interpolation between closest ranks (numpy / Excel `PERCENTILE.INC`). Thresholds: LCP "good" <= 2.5 s and "poor" > 4 s follow Google's Core Web Vitals; TBT > 600 ms and CLS > 0.25 are Lighthouse's "poor" bands. ## 6. Ethics and etiquette Public pages only. The forum was read through its public JSON API at one request per second with the User-Agent `TBP-threejs-study/1.0 (+https://thebaileyperspective.com/contact/)`. Each candidate site received at most three lightweight pre-check requests (no body read) and one Lighthouse page load (two if the first hung). No logins, no forms, no personal data beyond public topic titles and author-chosen URLs. Every site row keeps the forum topic it came from so the article can credit the makers and link to their posts rather than reproduce them. Anyone who wants their site removed from the dataset can use the contact address in the User-Agent.