# Cross-browser performance: why Firefox > Chrome > Safari, and how to close the gap > Research notes, **2026-06-18**. The KiCad WASM port runs fastest in Firefox, > slower in Chrome, slowest in Safari. This document explains *why* at the > browser-engine level and lays out a ranked, build-specific plan to speed up > Chrome and Safari. Web claims are dated and linked in [Sources](#sources); > codebase claims carry `file:line` refs. Companion work lives in > [`../async/`](../async/) (Asyncify) and [`../wasm-exceptions/`](../wasm-exceptions/) > (the `-fwasm-exceptions` migration). --- ## TL;DR The Firefox lead is **not** a Firefox trick. Our binary is dominated by **Asyncify** instrumentation, and Firefox's compilers simply tolerate Asyncify's pathological code far better than Chrome's or Safari's do. So the highest-leverage work for Chrome *and* Safari is to **shrink/attack the Asyncify footprint**, plus a handful of cheap, orthogonal wins. There are **two independent axes**, and both need attention: 1. **WASM compile/execute** — Asyncify-dominated. This explains the Firefox > Chrome > Safari **ordering**. 2. **WebGL rendering** — Safari's Metal/ANGLE overhead. This is *extra* Safari slowness on top of axis 1, and several fixes are one-liners. ### Ranked levers | # | Lever | Axis | Effort | Impact | Where | |---|---|---|---|---|---| | 1 | Confirm/force `instantiateStreaming` + `Content-Type: application/wasm` + stable URL/ETag | startup | hours | ~1.5–1.8× cold start (FF); arms V8 cache | `web/standalone/src/wasm/boot.ts` | | 2 | **Brotli** instead of gzip-9 on R2 | startup | hours | ~15–25% smaller transfer | R2 / edge config | | 3 | `powerPreference: 'high-performance'` + context-lost handlers | WebGL (Safari/Chrome) | hours | discrete GPU instead of integrated | `wxwidgets/src/wasm/glcanvas.cpp:524-535` | | 4 | Audit GAL shaders for the `flat` qualifier | WebGL (Safari) | hours–days | up to *seconds/frame* in worst case | `kicad/common/gal/shaders/` | | 5 | Remove `glGetError()` from the render loop | WebGL (Safari) | hours | avoids per-call Metal flush | GAL compositor | | 6 | Test `antialias: false` | WebGL (Safari) | hours | cuts MSAA resolve cost | `glcanvas.cpp:524-535` | | 7 | Enable `-msimd128` | WASM exec (all) | days | 1.5–2.5× geometry/render hot loops | build flags | | 8 | `ASYNCIFY_ADVISE` → `ASYNCIFY_IGNORE_INDIRECT` + extend `REMOVE` | WASM exec (all, esp. Chrome/Safari) | days | smaller binary + faster tier-up | `scripts/common/apply-asyncify.sh` | | 9 | `-fwasm-exceptions` (size) | WASM (all) | weeks | 64.5 → 36 MB gz | tracked — see [§ Structural bets](#structural-bets-track--prototype) | | 10 | JSPI (delete Asyncify) | WASM (all, esp. Safari) | weeks | ~40–50% smaller, removes JIT pressure | tracked — see [§ Structural bets](#structural-bets-track--prototype) | | — | wasm-split, WebGPU GAL backend | startup / WebGL | weeks+ | deferred (see [§ Deferred](#deferred--not-now)) | — | --- ## Current build (the baseline) Verified from the build scripts and runtime glue: | Knob | Value | Location | |---|---|---| | Asyncify | `-sASYNCIFY=1`, `ASYNCIFY_STACK_SIZE=65536` | `scripts/kicad/build-kicad-target.sh:~400` | | Exceptions | **legacy `-fexceptions`** (not `-fwasm-exceptions`) | `build-kicad-target.sh:240-255` | | SIMD | **none** (`-msimd128` absent) | — | | Threads | `-sUSE_PTHREADS=1`, pool = `navigator.hardwareConcurrency` (+ COOP/COEP) | `build-kicad-target.sh`, `web/.../preflight/capabilities.ts` | | Memory | `INITIAL_MEMORY=256MB`, `MAXIMUM_MEMORY=4GB`, `ALLOW_MEMORY_GROWTH=1` | `build-kicad-target.sh` | | Opt | clang `-O2` (release); link `-O0` then **host `wasm-opt -O2` after `--asyncify`** | `apply-asyncify.sh:88-157` | | WebGL | WebGL2 (`-sMAX_WEBGL_VERSION=2`), `antialias:true`, **`powerPreference:DEFAULT`** | `glcanvas.cpp:524-535` | | Loading | Emscripten script-glue; **streaming not confirmed**; gzip-9, **no Brotli** | `boot.ts:145-294` | | Artifact | pcbnew **186 MB raw / 64 MB gzip**; eeschema 99/34; pl_editor 52/17; gerbview 49/16 | `output/` | Note: all three modern browsers support `SharedArrayBuffer`/threads under COOP+COEP (the app demonstrably runs in each) — capability gating is in `capabilities.ts`, not UA sniffing. --- ## Why the ordering exists (engine internals) ### The villain: Asyncify Asyncify rewrites every instrumented function with unwind/rewind state checks and saves/restores all locals to linear memory. That expands each local's live range across the *whole* function, producing a nearly fully-connected interference graph — exactly the input that is catastrophic for optimizing register allocators. Asyncify's own docs warn: *"VMs may also limit compilation to the baseline tier on such pathological code."* Result: ~+70% binary, giant functions, and the 186 MB-raw pcbnew. See [`../async/02-asyncify-internals.md`](../async/02-asyncify-internals.md). ### How each engine copes | Engine | Baseline tier | Optimizing tier | On Asyncify's giant functions | |---|---|---|---| | **Firefox / SpiderMonkey** | Rabaldr, **~25 ns/byte**, eager whole-module, multithreaded (30–60 MB/s) | **Ion** — [75× large-function fix, Oct 2024](https://spidermonkey.dev/blog/2024/10/16/75x-faster-optimizing-the-ion-compiler-backend.html) (sorted live ranges, Semi-NCA dominators, sparse bitsets) targeting *exactly* the huge-CFG/high-vreg shape Asyncify creates (ONNX: 5 min → 3.9 s) | **Best.** Whole module baseline-compiled before download finishes; Ion swallows the big functions. No OSR gap. | | **Chrome / V8** | Liftoff, **~50 ns/byte** (½ Firefox) | **TurboFan** — chokes on huge fns (a 1.96 MB fn → 95 s, 7.4 GB RAM, 87% in regalloc); falls back to mid-tier allocator or **skips optimization** | **Middle.** **V8 has no OSR for wasm** — a function in a long loop (Asyncify rewind/unwind loops!) finishes that whole call in Liftoff; only the *next* call gets TurboFan. | | **Safari / JSC** | **Lazy everything**: IPInt (interpreter) → BBQ → OMG. Nothing eager. | **OMG** (B3) — did *not* get Ion's 2024 large-fn treatment | **Worst.** First run executes at interpreter speed; Asyncify ~doubles fn count → huge OMG backlog → documented **300–400% CPU spike for 30 s+** after a workload. **No persistent compiled-code cache**, so it re-pays every session; above ~10 MB it switches to a slower JIT mode. | ### Two corollaries that bite us specifically - **Chrome's V8 wasm code cache is effectively unavailable.** It only caches modules under ~150 MB *compiled*, and compiled code is 5–7× the `.wasm`. Our 186 MB pcbnew → ~1 GB compiled — far over the ceiling. So Chrome **re-runs TurboFan on every cold load** today. Shrinking the binary (levers 7–10) is the only way to get Chrome's repeat-load cache back. See [V8 wasm code caching](https://v8.dev/blog/wasm-code-caching). - **Benchmark trap:** with DevTools open, V8 tiers all wasm *down* to Liftoff. Never measure Chrome speed with DevTools open (except via an actual Performance recording, which forces tier-up). This likely makes Chrome look worse than it is in casual testing. --- ## The ranked plan ### Tier 1 — cheap, do now (days, low risk) **1. Confirm + force streaming instantiation and cache headers.** The loader injects the Emscripten JS glue via `