On February 5th, Domenic Denicola published a post titled "The Wrong Work, Done Beautifully." He has maintained jsdom for more than a decade. The post asks, in his own words: "Why use a Node.js reimplementation of a web browser, when you could instead drive a real headless web browser?"
That question would sting less coming from an outsider. jsdom pulled 69.5 million downloads from npm in the last week of May. It sits under most of the JavaScript ecosystem's component tests, the invisible default that Jest popularized and Vitest inherited. And its own maintainer is no longer sure the niche exists.
The timing matters. Vitest 4.0 stabilized browser mode on October 22, 2025, after roughly two years in experimental status. Storybook now compiles stories directly into browser-mode tests. The conditions that made DOM simulation necessary have quietly disappeared. What remains is an enormous installed base, a migration bill, and a genuinely hard question about where the line should sit.
How a fake browser became the default test target
jsdom predates the entire modern frontend stack. It is a JavaScript implementation of the DOM, HTML, and a long tail of adjacent web specs, built to run inside Node.js with no browser anywhere in sight. When Jest adopted it as the default test environment, an entire idiom grew on top: call render(), get a document that exists in-process, query it with Testing Library, fire synthetic events, assert on the result.
Simulation won for operational reasons, not philosophical ones. A jsdom test is just a Node process. It starts in milliseconds, parallelizes across workers for free, and needs no browser binary in CI. In 2016 the alternative meant Selenium grids, version-skewed drivers, and a flakiness tax nobody wanted to pay for unit-level coverage. Choosing the fake DOM was the rational call.
The contract was always explicit, even if most teams stopped reading it. jsdom parses HTML, executes scripts, and tracks the specs obsessively. It does not lay out. It does not paint. It does not scroll. getBoundingClientRect() returns zeros. CSS exists as parsed syntax, never as computed layout. happy-dom later made the same bet with a smaller spec surface and faster execution, and Vitest let you pick either with a one-line environment setting.
For a decade that contract was acceptable because the alternative was worse. The alternative stopped being worse.
The maintainer's confession
Denicola's February post is not an abandonment notice. It is stranger than that. Asked to join an AI coding productivity study, he spent three weeks running Claude Code against jsdom's backlog. The result was a rewrite of the resource loading subsystem across two pull requests of 43 and 77 commits, five hard issues closed including one open since 2016, and the release of jsdom v28.0.0. A new contributor has since taken on the selector and CSS subsystems. v29.0.0 followed in March, and v29.1.1 landed on April 30.
So the project is shipping. The doubt is about purpose, not energy. For scraping, he points out, cheerio is enough. For real interaction with real pages, headless Chromium is right there. What jsdom offers is, in his words, "an obsessively spec-compliant, script-executing, but very much partial implementation of the web platform." He finds it "hard to believe there's a large market" for that niche.
There is a second observation buried in the post that deserves its own essay: he wonders whether AI agents make low-priority work addictive, because fixing a backlog suddenly feels frictionless whether or not the backlog matters. Anyone experimenting with agent-driven coding workflows will recognize the trap. The tooling makes motion cheap. It does not make direction cheap.
Every new web platform feature sharpens his question. Container queries, anchor positioning, view transitions: each one has to be reimplemented by hand, in JavaScript, by a tiny team, to keep the simulation honest. The jsdom issue tracker carries an open request for container query support. A real browser gets all of it for free, forever.
What the simulation cannot fake
The failure surface of a simulated DOM is not random. It clusters around everything that requires layout or rendering: sticky headers, virtualized lists that measure row heights, focus order that depends on visibility, scroll-driven behavior, drag and drop, canvas, and animation timing. happy-dom adds its own gaps, including Shadow DOM support that still trails jsdom's.
Observers are the sharpest example. Neither jsdom nor happy-dom performs real intersection math; their IntersectionObserver implementations are stubs. If a component lazy-loads on visibility, the standard move is to mock the observer, at which point the test asserts the behavior of the mock. The suite goes green while testing jsdom's opinion of your component rather than the component.
These are not exotic edge cases. They are the exact behaviors that determine how an interface feels under real input, the same class of work that decides whether you keep Interaction to Next Paint under 200 ms. A test environment that cannot observe layout cannot tell you anything about the failures users actually notice.
What Vitest 4 actually shipped

Browser mode inverts the old architecture instead of patching it. Vitest starts a Vite dev server, opens a real browser through a provider, and executes your test files inside an iframe on a real rendering engine. The 4.0 release split providers into dedicated packages: @vitest/browser-playwright drives Chromium, Firefox, and WebKit and is the recommended path for CI; @vitest/browser-webdriverio covers WebDriver targets; @vitest/browser-preview simulates events and exists for quick local feedback only.
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
})The test-writing idiom survives almost untouched, and that is deliberate. Locators mirror Testing Library queries, interactions hang off the locator, and assertions go through expect.element(), which retries until timeout the way Playwright assertions do. A React component test reads like this:
import { render } from 'vitest-browser-react'
import { page } from 'vitest/browser'
import { expect, test } from 'vitest'
test('increments the counter', async () => {
const screen = render(<Counter initialValue={0} />)
await expect.element(screen.getByText('Count: 0')).toBeInTheDocument()
await page.getByRole('button', { name: /increment/i }).click()
await expect.element(screen.getByText('Count: 1')).toBeInTheDocument()
})Because the test runs on a real engine, assertions that were impossible in simulation become one-liners. toBeInViewport uses the actual IntersectionObserver. toMatchScreenshot brings visual regression into the unit-test loop, which catches the category of change markup assertions never see: a design token edit that shifts spacing across forty components produces a screenshot diff and zero failed DOM queries.
Vitest 4.1, released March 12, 2026, kept investing in the same direction: Playwright trace support with automatic grouping of browser interactions, page.mark() annotations for the trace viewer, test tags for slicing suites, and an agent reporter built for AI coding harnesses that consume test output programmatically.
None of this is a new idea, which makes the execution the interesting part. Cypress shipped component testing years ago, and Playwright still labels its component-testing package experimental. Neither dislodged jsdom, because both arrived as a second test runner with its own config, its own CI setup, and its own mental model sitting next to the unit suite. Browser mode is the same runner, the same config file, the same vi API, and the same watch mode. The environment changes underneath the test; the workflow does not.
Jest, meanwhile, has no answer here. Since Jest 28 the jsdom environment has lived in a separate jest-environment-jsdom package, but there is no real-browser execution path on the roadmap. Teams that want this capability switch runners to get it, which quietly turns the simulation question into another front in the Jest-to-Vitest migration that has been running for years.
Storybook closed the loop from the other side. Its Vitest addon transforms stories into real Vitest tests executed in browser mode through Playwright, replacing the old standalone test-runner. A story stops being documentation that happens to render and becomes a render test, an interaction test, and an accessibility check in one artifact. For component-library teams, the separate test file for "does it render" is now redundant work.
The part nobody benchmarks

Real browsers cost real money, and the costs hide in unexpected places. Vitest issue #9323 documents a team that moved exactly three tests out of a 507-test suite into browser mode and watched total GitHub Actions time go from 284.83 seconds to 473.52 seconds, with the remaining jsdom tests mysteriously slowing down after the browser phase. The issue closed as not-planned for lack of a reproduction, but the shape of the complaint is common: mixed suites interact badly on the two-vCPU runners most teams use by default.
Mocking is the deeper migration cost. Browser mode runs real ES modules, and ESM namespaces are sealed, so vi.spyOn() on a module export does not work. The supported pattern is vi.mock('./module.js', { spy: true }), and exported variables cannot be mocked at all without wrapping them in functions. Suites built on years of casual module interception will spend most of their migration budget here, not on rewriting queries. Thread-blocking dialogs like alert get mocked by default since they would hang the run. And the friction cuts both ways: recent jsdom releases have their own compatibility problems under Vitest 4, tracked in issue #9279.
| jsdom | happy-dom | Browser mode | |
|---|---|---|---|
| What it is | Spec-focused DOM reimplementation in JS | Faster, smaller DOM reimplementation | Real engine driven by a provider |
| Layout and paint | None; rects return zeros | None | Real layout, real paint |
| IntersectionObserver | Stub, no intersection math | Stub, no intersection math | Native |
| Startup cost | Milliseconds, plain Node process | Milliseconds, lighter than jsdom | Browser launch, amortized over the suite |
| CI requirements | None beyond Node | None beyond Node | Browser binaries, caching, more CPU |
| Module mocking | Full vi.spyOn / vi.mock surface | Full surface | Sealed ESM; spy via vi.mock only |
| Best fit | Logic-heavy tests, light DOM assertions | Same, when speed dominates | Anything touching layout, visibility, or input |
The adoption numbers say both stories are true at once. For the last week of May 2026, npm reports 69.5 million weekly downloads for jsdom and 8.6 million for happy-dom, against 4.1 million for @vitest/browser-playwright, 2.8 million for @storybook/addon-vitest, and about a million for vitest-browser-react. A roughly seventeen-to-one installed-base gap does not close because a maintainer published doubts. But the browser-mode packages went from zero to millions of weekly installs in seven months, and defaults for new projects are where this kind of shift always shows first.
How to actually split a suite

Wholesale migration is the wrong move for an existing codebase. Vitest's projects configuration runs a jsdom project and a browser project side by side in one invocation, so the split can be incremental and permanent rather than a rewrite:
export default defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
},
},
{
test: {
name: 'browser',
include: ['src/**/*.browser.test.tsx'],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
},
],
},
})A file naming convention does the routing, both projects share one command and one coverage report, and the interesting question becomes which tests earn the browser.
Migrate first:
- Any test that currently mocks
IntersectionObserver,ResizeObserver, orgetBoundingClientRect. Those tests assert mocks today. - Virtualized lists, sticky positioning, scroll behavior, focus and keyboard navigation, drag and drop, canvas.
- Design-system components, where
toMatchScreenshotreplaces a separate visual-testing service. - Storybook stories, via the Vitest addon, if you maintain them anyway.
Leave alone anything that is really a logic test wearing a DOM costume: hooks without rendering, reducers, formatting, simple render-and-assert-markup cases. jsdom remains the right tool there, and it is not close. The simulation is fast, the fidelity requirements are nil, and the mocking ergonomics are better.
Two CI adjustments prevent most of the reported pain. Give the browser project its own job so its resource appetite cannot starve the jsdom workers, and cache the Playwright browser binaries between runs. Teams on default GitHub runners should also raise testTimeout for the browser project; assertions that retry, like expect.element(), absorb slow cold starts instead of flaking.
Simulation becomes the opt-in
Nothing about jsdom got worse. It is shipping major versions faster in 2026 than it has in years, with new contributors its maintainer describes warmly. What changed is the default that surrounds it. For ten years, simulation was the baseline and a real browser was the expensive thing you justified. Vitest 4 and the Storybook integration flipped the polarity: the real browser is the baseline for component behavior, and simulation is the optimization you opt into, knowingly, for tests that never needed a browser in the first place.
That inversion is healthy, and it is also exactly what Denicola's post predicted from the inside. The discomfort in "The Wrong Work, Done Beautifully" is not that jsdom is broken. It is that an obsessively correct partial reimplementation of the web platform may no longer be the right layer to spend correctness on. Sixty-nine million weekly downloads will take years to drain, and plenty never will. The question for your team is smaller and answerable this quarter: which of your green tests are asserting your component, and which are asserting jsdom's opinion of it?
