Plate 82
jsdom vs happy-dom: Should You Switch? 1.9x Faster, 2 Gaps
jsdom vs happy-dom decision with ShopperCove timing in Vitest 5.0.3: 20-file / 60-test suite median 1.65 s on happy-dom 20.14.5 vs 3.11 s on jsdom 30.1.2; heavy DOM 1.33 s vs 1.81 s (jsdom 29.1.1 2.55 s). Compat 8/8 jsdom 30, 6/8 happy-dom, 5/8 jsdom 29.
Aditya Challa6 min read
On my 20-file, 60-test Vitest 5.0.3 suite, happy-dom 20.14.5 finished in a median 1.65 s versus 3.11 s for jsdom 30.1.2, about 1.9x faster. But happy-dom failed 2 of my 8 DOM behavior checks, so switch only if your tests stay clear of the gaps listed below.
Short answer: use happy-dom for large suites of simple render-click-assert tests, and keep jsdom 30 for anything touching focus, table APIs or computed CSS. No affiliate links in this post.
What I tested
- Machine: 8 vCPU / 15 GB RAM Linux cloud box, Node 24.21.0, tested 5 Oct 2026 (IST).
- Runner: Vitest 5.0.3 with Vite 8.3.2, default
forkspool, environment set invitest.config.ts. - Environments: jsdom 30.1.2 (released 4 Oct 2026) and happy-dom 20.14.5 in one project; jsdom 29.1.1 in a second, otherwise identical project.
- Light suite: 20 files × 3 tests (form submit, a 50-item list, a click that flips
aria-expanded). - Heavy suite: 4 files × 2 tests (build a 2,000-row table with
createElementand sort it; parse 2,000 rows throughinnerHTMLand readgetComputedStyleon 200 cells). - Timing: three runs each, median of Vitest's reported Duration. Wall clock with process start is listed further down.
- One surprise: my first heavy test sorted rows with
tbody.rows. Under happy-dom it crashed withTypeError: undefined is not iterable. I rewrote the timing test to usequerySelectorAll('tr')so both environments run the same work, and kepttbody.rowsas a separate compatibility check. - Not tested: React Testing Library or user-event, memory use, Vitest browser mode, Windows or macOS, a large production suite.
Is happy-dom faster than jsdom in Vitest?
| Suite | jsdom 30.1.2 | happy-dom 20.14.5 | jsdom 29.1.1 |
|---|---|---|---|
| Light: 20 files / 60 tests | 3.11 s (3.07 / 3.11 / 3.14) | 1.65 s (1.54 / 1.65 / 1.70) | 2.93 s (3.11 / 2.93 / 2.64) |
| Heavy: 4 files / 8 tests | 1.81 s (1.81 / 1.94 / 1.79) | 1.33 s (1.34 / 1.33 / 1.27) | 2.55 s (2.50 / 2.55 / 2.56) |
| Light, wall clock median | 3.45 s | 2.00 s | 3.25 s |
| Heavy, wall clock median | 2.14 s | 1.68 s | 2.92 s |
On the light suite, Vitest said 90–92% of jsdom's time was environment setup, against 82–83% for happy-dom. The win there is per-file startup, so it grows with the number of test files, not the number of assertions. On the heavy suite the gap narrowed to about 1.36x.
jsdom 30 itself is a real upgrade for DOM-heavy tests: the heavy suite went from 2.55 s on 29.1.1 to 1.81 s on 30.1.2, about 29% faster. That matches the 30.1.0 release notes on faster DOM construction and the 30.1.2 fix for slow large-tree builds. The light suite showed no gain (3.11 s vs 2.93 s, and 29.1.1's runs spread from 2.64 s to 3.11 s).
What breaks when you switch to happy-dom?
| Check | jsdom 30.1.2 | happy-dom 20.14.5 | jsdom 29.1.1 |
|---|---|---|---|
C1 getComputedStyle reads a <style> color | pass | pass | pass |
C2 CSS.escape('a.b') | pass | pass | fail (CSS undefined) |
C3 .card:has(img) selector | pass | pass | pass |
C4 empty required input checkValidity() is false | pass | pass | pass |
C5 focus() skips a display:none input | pass | fail | fail |
C6 width: 2em at 10px font computes to 20px | pass | pass | fail (2em) |
C7 getBoundingClientRect().width is 0 (no layout) | 0 | 0 | 0 |
C8 tbody.rows and tr.cells | pass | fail | pass |
| Total | 8 / 8 | 6 / 8 | 5 / 8 |
The happy-dom C8 failure is narrow: table.rows and tr.cells worked in a direct check, but tbody.rows (on HTMLTableSectionElement) returned undefined. The C5 failure matters more for accessibility tests: happy-dom moved focus onto a hidden input, so a test asserting "focus lands on the first visible field" can pass when it should fail.
C7 is a reminder for both: neither environment does layout. If a test depends on real sizes, scroll positions or geometry-based IntersectionObserver, use Vitest browser mode or Playwright. I covered Playwright's newest test-runner change in the Playwright 1.63 post.
Does jsdom 30 need a newer Node?
Yes. jsdom 30 requires Node ^22.22.2 || ^24.15.0 || >=26.0.0. On Node 22.20.0, npm i jsdom@30.1.2 printed EBADENGINE warnings (first for its @asamuzakjp/css-color 7.1.3 dependency). My compat file still passed 8/8 there, but that is unsupported. happy-dom 20.14.5 only asks for Node 20+. If you cannot move Node yet, jsdom 29.1.1 or happy-dom are your options; the Node 26 LTS schedule post covers the upgrade timing.
How do I switch without a big-bang change?
- Keep jsdom as the default
environmentinvitest.config.ts. - Add
// @vitest-environment happy-domat the top of the files that only render, click and read text. Those gain the most, because setup dominates them. - Run the whole suite under both environments in CI for a week (
environmentfrom an env var works, as in my config) and compare failures. - Grep your tests for
.focus(,tBodies,.rowsandgetComputedStylebefore you move a file. Those were the risky areas here. - If you are already on Vite 8.3.2, no other config change was needed for either environment.
Should you switch from jsdom to happy-dom?
| Situation | My pick |
|---|---|
| Hundreds of small component tests (render, click, assert text) | happy-dom, after checking the gaps above |
| Tests for focus management, table APIs or computed CSS | jsdom 30 |
| Stuck on Node below 22.22.2 | jsdom 29.1.1 or happy-dom |
| Tests that need real layout or geometry | Neither: browser mode or Playwright |
Bottom line: happy-dom cut my light suite from 3.11 s to 1.65 s, and that saving scales with file count. It also got 2 of 8 behavior checks wrong, and one of them (focus on hidden inputs) can hide real accessibility bugs. Who should not switch: teams whose tests exist mainly to catch focus and keyboard regressions.
How this was made: I ran every command above on the ShopperCove test box and kept the raw output; the write-up was drafted with AI help and checked against that output.
Sources
- https://github.com/jsdom/jsdom/releases
- https://github.com/jsdom/jsdom/releases/tag/v30.1.2
- https://github.com/capricorn86/happy-dom
- https://github.com/capricorn86/happy-dom/releases
- https://vitest.dev/guide/environment
- https://www.npmjs.com/package/jsdom
- https://www.npmjs.com/package/happy-dom
- https://www.npmjs.com/package/vitest
Related
- https://www.shoppercove.com/blog/playwright-1-63-test-locks-october-2026
- https://www.shoppercove.com/blog/vite-8-3-2-renderbuilturl-bundled-dev-sourcemaps-october-2026
- https://www.shoppercove.com/blog/vite-plus-1-0-unified-toolchain-october-2026
- https://www.shoppercove.com/blog/nodejs-26-lts-october-2026-schedule-change
- https://www.shoppercove.com/blog/react-19-3-view-transitions-fragment-refs-2026
- https://www.shoppercove.com/blog/chrome-155-stable-jpeg-xl-css-changes-october-2026
- https://www.shoppercove.com/blog/pnpm-12-9-1-rust-rewrite-wasm-split-october-2026
- https://www.shoppercove.com/blog/oxlint-1-87-react-suggestions-a11y-fixes-october-2026
Lab evidence
What I found running this
Sources read 5 Oct 2026 evening (Asia/Calcutta): github.com/jsdom/jsdom/releases (v30.0.0 27 Jul 2026 Node ^22.22.2||^24.15.0||>=26; v30.1.0 17 Sep perf; v30.1.2 4 Oct 2026 ~14:45 IST large-tree slowdown fix, focus display:none fix); npm jsdom latest 30.1.2, happy-dom 20.14.5 (engines node >=20), vitest 5.0.3 (peer jsdom/happy-dom '*'). Hands-on on box (8 vCPU/15 GB, Node 24.21.0, Vitest 5.0.3 + Vite 8.3.2, forks pool, 3 runs, Vitest Duration): light 20 files/60 tests: jsdom 30.1.2 3.07/3.11/3.14 s; happy-dom 1.54/1.65/1.70 s; jsdom 29.1.1 3.11/2.93/2.64 s. Heavy 4 files/8 tests (2,000-row tables, 200 getComputedStyle): jsdom 30 1.81/1.94/1.79 s; happy-dom 1.34/1.33/1.27 s; jsdom 29 2.50/2.55/2.56 s. Wall medians light 3.45/2.00/3.25 s, heavy 2.14/1.68/2.92 s. Compat 8 checks: jsdom 30 8/8; happy-dom 6/8 (fails focus() on display:none input, tbody.rows undefined while table.rows/tr.cells work); jsdom 29.1.1 5/8 (no CSS.escape, focus hidden, 2em not converted to px). Node 22.20.0: npm EBADENGINE for jsdom 30 deps; compat still 8/8 (unsupported). No ShopperCove production suite changed. No affiliate.
Related links
Plate 12
Yarn Berry vs pnpm: 1.3s vs 0.24s Install
Yarn 4.9.2 (Berry PnP + node-modules linker) vs pnpm 12.9.1 on one 22-dependency React+Vite app. Isolated caches (Yarn enableGlobalCache false; pnpm --store-dir). Warm medians (5): Yarn PnP 1.307 s, Yarn nm 2.471 s, pnpm 0.240 s. Lock+empty medians 1.656 / 2.789 / 0.725 s. Clean disk: Yarn PnP 246 MB; Yarn nm modules 189 MB; pnpm hardlink project 167 MB. Fixture /workspace/bench-yarn-pnpm/. Distinct from pnpm-vs-npm-vs-bun (no Yarn).
5 Oct 2026
Plate 48
Zod vs Valibot: 1.14M vs 0.97M Parses
zod 4.6.5 vs valibot 1.5.0 on one nested user schema (uuid/email/age/tags/profile/enum). Warm medians after 2k warmup + 9×50k: zod.parse 1,144,494 ops/s vs valibot.parse 968,187 (~1.18×). safeParse fail: valibot 697,760 vs zod 415,523 (~1.7×). esbuild minify+gzip9: 452,999/92,636 B vs 85,126/15,422 B. Package trees ~6.1 MB vs ~1.9 MB. Fixture /workspace/bench-zod-valibot/.
5 Oct 2026
Plate 55
mozjpeg vs libvips: 385ms vs 31ms Encode
mozjpeg cjpeg 3.3.1 (mozjpeg npm 8.0.0) vs libvips jpeg via sharp 0.35.5 (mozjpeg:false) on one 1920×1080 JPEG → q80. Warm medians (7 rounds after 1 warm-up): cjpeg 385 ms / 675,128 B vs libvips jpeg 31 ms / 675,714 B (~12×). Context: sharp mozjpeg:true 308 ms / 546,546 B. Fixture /workspace/bench-mozjpeg-libvips/. cjpeg timed with spawn + PPM read.
5 Oct 2026