# The download 2026-07-31. What a device actually fetches on a first visit, what it could fetch, and what each reduction costs. Measured against the deployed site and against real builds — no estimates. ## First, a correction `docs/assembly-load.md` said 4.4 MB per device. That is the size **on disk**. The server compresses, so what a phone actually downloads today is **~1.7 MB**: | asset | on disk | over the wire | why | |-|-|-|-| | wasm | 3,990 KB | **1,521 KB** | gzip | | style.css | 127 KB | 23 KB | gzip | | JS glue | 70 KB | 17 KB | gzip | | material-icons.woff2 | 125 KB | **125 KB** | woff2 is already compressed; gzip does nothing | | Atkinson 400 + 700 | 34 KB | 34 KB | same | | Atkinson italics | 36 KB | 0 KB | fetched only if italic text is rendered | | other css | 12 KB | ~4 KB | gzip | | **total** | **4.4 MB** | **~1.72 MB** | | For 500 people that is ~860 MB through the venue uplink, not 2.2 GB. Still the largest single thing the assembly will pull, and still worth halving. ## What is in the wasm Nothing wasteful at the section level. The shipped binary carries **no debug sections and no name section** — `scripts/split-symbols.nu` moves the DWARF into a 29 MB sidecar that only the backend ever fetches, to turn crash reports into source lines. Of the 3,990 KB: 3,544 KB is code (6,685 functions) and 500 KB is data. It is all program. The build was already tuned: `[profile.wasm-release]` has `lto = "fat"`, `codegen-units = 1`, `panic = "abort"`, and wasm-opt runs at `-Oz`. Roster parsing (calamine/zip) was moved to the backend earlier for the same reason. ## The three levers, measured ### 1. `opt-level = "s"` → `"z"` — 214 KB, free Built both and measured the same commit: | | raw | gzip | brotli | |-|-|-|-| | `s` (what shipped) | 3,990 KB | 1,433 KB | 992 KB | | `z` | **3,263 KB** | **1,219 KB** | **879 KB** | 18% smaller before compression. `z` tells LLVM to stop inlining for speed; in an app that spends its life waiting on the network, that trade is one-sided. The `z` build was loaded in a browser and renders correctly. **Applied.** ### 2. Brotli instead of gzip — ~340 KB, not ours to switch on statichost serves **gzip only**. Asking for `Accept-Encoding: br` returns the uncompressed file. On the same wasm, brotli -11 against gzip -9 is **1,219 KB → 879 KB**. That is the biggest remaining number, and no code change can match it. Two ways: - **Ask statichost to enable brotli.** Cleanest, costs nothing, benefits every asset. Worth an email before the assembly. - **Ship pre-compressed bytes** with `Content-Encoding: br` set on that path in `_headers` (which statichost does honour — the `immutable` cache rule proves it). The catch is that it is unconditional: a client that does not accept brotli gets bytes it cannot read. Every browser that can run wasm has supported brotli for years, so the practical risk is low, but it is a real one, and it should be probed with a throwaway file on a deploy before the wasm depends on it. ### 3. Subset the icon font — 89 KB, verified safe The font carries **2,233 icons**; the app can render **105** of them. Subsetting to those (`pyftsubset --layout-features=liga`) gives **125 KB → 36 KB**. Verified rather than assumed: both fonts were loaded in a browser and every one of the 105 names measured with canvas `measureText`. **Zero width mismatches against the full font, and zero ligatures that failed to form** — so every icon still renders exactly as before. Not yet applied, because doing it properly means generating the subset at build time from the full font (so a newly-used icon cannot silently render as the word `add_reaction`), which needs `fonttools` in the devshell and the Nix package. The guard rail worth having with it: the icon list is derived by intersecting every string literal in `src/` with the font's own ligature names, so a test can hold that same invariant without parsing a font. ## Also worth doing, no bytes involved **Preload the wasm.** The served `index.html` preloads the 17 KB JS glue but not the 1.5 MB wasm, so the browser only learns about the big download after the glue has arrived and started — an extra round trip on exactly the connection where round trips hurt. A `` injected at build time (the filename is content-hashed, so it has to be injected, not templated) starts both at once. Needs checking in a browser first: a preload whose `as`/`crossorigin` do not match the real request downloads the file **twice**, which would be worse than the problem. ## Rejected, with reasons - **Dropping the DWARF line tables** (`debug = "line-tables-only"`) saves ~2% and costs every crash report its source line. Not worth it. - **The italic faces** are already only fetched when italic text renders. - **The CSS** is 23 KB gzipped. There is nothing there. - **The 29 MB symbol sidecar** is never fetched by a reader — it is already off the critical path. ## Where it lands | | today | with all three | |-|-|-| | per device | ~1.72 MB | **~0.95 MB** | | 500 devices | ~860 MB | **~470 MB** | ## The next frontier, unmeasured Dependency-level: `reqwest` (with multipart), `cynic`, `serde_json`, `material-colors`, `dioxus-primitives`. I could not attribute size to them — the shipped wasm is stripped and the sidecar has DWARF but no name section, so twiggy reports `code[0]`, `code[1]`… To do it properly, build once retaining the name section and run `twiggy top` against that. Only worth it if the numbers above turn out not to be enough; each of them is bigger than most dependency wins, and none of them risks changing behaviour.