|
| 1 | +# vhost benchmarks |
| 2 | + |
| 3 | +Measures the per-request cost of the middleware returned by `vhost(...)`: hostname |
| 4 | +matching plus `req.vhost` population. Handlers are no-ops so only the library's work |
| 5 | +is timed. |
| 6 | + |
| 7 | +```sh |
| 8 | +# quick single-process snapshot (tinybench) |
| 9 | +node bench/index.mjs |
| 10 | + |
| 11 | +# rigorous comparison: N independent sessions × several iteration lengths |
| 12 | +node bench/run-matrix.mjs 8 10000 100000 1000000 |
| 13 | +``` |
| 14 | + |
| 15 | +Lower ns/op is better. Numbers are machine- and load-dependent — what matters is the |
| 16 | +relative change between versions measured on the same machine. Per-op figures are |
| 17 | +comparable across the CJS baseline and the ESM build because module-load cost is paid |
| 18 | +once per process, not per operation. |
| 19 | + |
| 20 | +The v3 implementation is pinned at `bench/v3-baseline.cjs` (a copy of the original |
| 21 | +`index.js`) so the old-vs-new comparison stays reproducible after the rewrite. |
| 22 | + |
| 23 | +Environment: Node.js v24.15.0, darwin, arm64 (Apple Silicon). |
| 24 | + |
| 25 | +## Baseline — v3.0.2 (`index.js`, CommonJS) |
| 26 | + |
| 27 | +Every request runs `RegExp.exec` and, on a match, allocates a capture array and loops |
| 28 | +to copy groups onto `req.vhost` — even for an exact static hostname with no captures. |
| 29 | + |
| 30 | +| scenario | ops/sec | ns/op | |
| 31 | +| --------------- | -----------: | ----: | |
| 32 | +| static match | 11,666,664 | 88.5 | |
| 33 | +| static no-match | 23,946,474 | 40.9 | |
| 34 | +| wildcard match | 6,779,127 | 160.7 | |
| 35 | +| regexp match | 5,896,864 | 195.2 | |
| 36 | +| no Host header | 35,641,036 | 21.2 | |
| 37 | + |
| 38 | +`static match` is the hot path optimized in v4: an exact hostname like |
| 39 | +`mail.example.com`, the overwhelmingly common real-world usage. |
| 40 | + |
| 41 | +## v4.0.0 (`dist/index.js`, TypeScript, ESM) |
| 42 | + |
| 43 | +For a static (no-`*`) hostname the middleware writes `req.vhost` directly with |
| 44 | +`length: 0`, skipping `exec`'s capture array, the group-copy loop, and the result |
| 45 | +allocation on a miss. Matching uses an **ASCII-gated decision** that is provably |
| 46 | +identical to `regexp.test()`: |
| 47 | + |
| 48 | +- For an ASCII literal pattern, the precompiled lowercase form `lowered` and its |
| 49 | + length are cached. At request time: |
| 50 | + - `name.length !== lowered.length` → **definite non-match**. RegExp `i` uses Unicode |
| 51 | + *simple* case folding, which is 1:1 (length-preserving) and never folds a multi-char |
| 52 | + or astral sequence into an ASCII pattern character, so a length mismatch can never be |
| 53 | + a match. This rejects misses without touching the regex. |
| 54 | + - `name === lowered` → **definite match** (ASCII, 1:1 fold). |
| 55 | + - otherwise (mixed case) → defer to the **identical** `regexp.test(name)`. |
| 56 | +- For a non-ASCII literal pattern, matching defers wholly to `regexp.test`, because |
| 57 | + non-ASCII folds can change length (e.g. `'İ'.toLowerCase()` is two code units) and |
| 58 | + code points such as U+212A (Kelvin) fold to ASCII under the regex but not under |
| 59 | + `toLowerCase()`. A naive lowercase compare would change matching — so it is never used. |
| 60 | + |
| 61 | +Wildcard string hostnames keep the `exec` + capture path but gain a cheap, always-safe |
| 62 | +minimum-length reject: a match can never be shorter than the pattern's literal characters |
| 63 | +plus one character per `*`, so a too-short hostname is rejected before the regex runs (it |
| 64 | +only ever rejects; long-enough hosts fall through to the identical regex). RegExp hostnames |
| 65 | +are unchanged. The contract is byte-for-byte preserved: the full 56-case suite passes |
| 66 | +unchanged against the build, including regression tests for the Kelvin sign, the `İ` |
| 67 | +length-changing fold, and wildcard prefix/suffix capture + case-insensitivity. |
| 68 | + |
| 69 | +### Multi-session matrix: v3 baseline vs v4 |
| 70 | + |
| 71 | +`node bench/collect.mjs 25 10000 100000 1000000` — 25 independent sessions (one fresh |
| 72 | +`node` process each, so independent JIT/GC state) per length. The table below is the |
| 73 | +**100,000-iteration** length, which had the lowest variance in this run; the full sweep |
| 74 | +(all three lengths, raw per-session data) is stored under `bench/results/`. `old`/`new` |
| 75 | +are mean ns/op; the **median** is shown too because a single OS-scheduler spike can inflate |
| 76 | +the mean/max (prefer the median when `±sd%` is high). `speedup` is `old mean ÷ new mean`. |
| 77 | +The `static`/`wildcard` scenarios use a lowercase Host — the realistic common case. |
| 78 | + |
| 79 | +| scenario | old mean | new mean | new median | speedup | ±sd% | |
| 80 | +| -------------------------- | -------: | -------: | ---------: | ------: | ---: | |
| 81 | +| static match | 84.5 | 61.8 | 61.4 | 1.37× | 3.4% | |
| 82 | +| static no-match | 27.5 | 14.3 | 14.2 | 1.92× | 5.0% | |
| 83 | +| wildcard match | 149.7 | 149.6 | 149.5 | 1.00× | 2.0% | |
| 84 | +| wildcard match (prefix) | 151.0 | 151.7 | 150.8 | 1.00× | 3.3% | |
| 85 | +| wildcard no-match (short) | 29.7 | 13.5 | 13.4 | 2.20× | 2.7% | |
| 86 | +| wildcard no-match (suffix) | 33.6 | 33.3 | 33.1 | 1.01× | 2.0% | |
| 87 | +| multi-star match | 163.2 | 163.2 | 163.0 | 1.00× | 1.7% | |
| 88 | +| regexp match | 166.6 | 166.1 | 165.3 | 1.00× | 1.8% | |
| 89 | +| no Host header | 7.3 | 5.9 | 5.9 | 1.24× | 1.8% | |
| 90 | + |
| 91 | +**Reading the results.** |
| 92 | + |
| 93 | +- **Static** match **~1.37×**, no-match **~1.9×**, no-Host **~1.24×** — the ASCII-gated |
| 94 | + decision (above) deciding most requests without the regex. |
| 95 | +- **Wildcard short no-match ~2.2×** — the minimum-length reject fires before the regex. |
| 96 | +- **Wildcard match / prefix / suffix / multi-star, and RegExp: ~1.0×** — neutral, no |
| 97 | + regression. The regex still does the matching and capture here; only too-short hosts are |
| 98 | + short-circuited. (At 1,000,000 iterations a couple of these means dip to ~0.93–0.94× from |
| 99 | + single OS-scheduler spikes — their medians stay 1.00–1.01×, which is why medians are |
| 100 | + reported alongside means.) |
| 101 | + |
| 102 | +### What did *not* work on the wildcard path |
| 103 | + |
| 104 | +A hand-rolled single-`*` string matcher (decompose `PREFIX*SUFFIX`, compare prefix/suffix |
| 105 | +with an ASCII case-fold loop, slice out the capture, fall back to the regex for |
| 106 | +multi-`*`/non-ASCII) *looked* ~1.5× faster on matches in isolation — but that was a |
| 107 | +**measurement artifact**: the prototype built `req.vhost` as a fast object literal, whereas |
| 108 | +the contract requires `Object.create(null)` populated incrementally. Once the prototype |
| 109 | +used the real allocation, the matcher came out **0.62–0.71× (30–38% slower)** on matches, |
| 110 | +because the null-prototype result allocation dominates the per-request cost and dwarfs any |
| 111 | +saving from skipping `exec`. So the wildcard change is limited to the always-safe |
| 112 | +minimum-length reject. The experiment lives in `bench/wildcard-experiments.mjs` (variants |
| 113 | +WV1–WV4, each fuzz-checked for identical captures against the regex) with the lesson |
| 114 | +documented at the top of the file. |
| 115 | + |
0 commit comments