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.
6 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.
forks pool, environment set in vitest.config.ts.aria-expanded).createElement and sort it; parse 2,000 rows through innerHTML and read getComputedStyle on 200 cells).tbody.rows. Under happy-dom it crashed with TypeError: undefined is not iterable. I rewrote the timing test to use querySelectorAll('tr') so both environments run the same work, and kept tbody.rows as a separate compatibility check.| 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).
| 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.
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.
environment in vitest.config.ts.// @vitest-environment happy-dom at the top of the files that only render, click and read text. Those gain the most, because setup dominates them.environment from an env var works, as in my config) and compare failures..focus(, tBodies, .rows and getComputedStyle before you move a file. Those were the risky areas here.| 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.
Lab evidence
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.
Both cut a string at a separator, but they fail in different ways. We ran them on Python 3.13.5 to show where partition() is safer and where split() is the right tool.
Summary2 Nov 20263 min read
What changes when you swap sort() for toSorted(), with a real 1-million-item Node.js benchmark and the support table.
Summary1 Nov 20263 min read
Dragging videos is the official way to change a playlist's order. Here are the steps from YouTube Help, where to find them in YouTube Studio, and the cases where reordering will not work.
Summary31 Oct 20263 min read