Choosing Between jsdom, happy-dom and Browser Mode

Vitest can run component tests in three kinds of environment: jsdom, a mature JavaScript implementation of the DOM; happy-dom, a faster and lighter alternative with a smaller feature set; and browser mode, which runs tests inside a real Chromium, Firefox or WebKit through Playwright or WebdriverIO. Teams often pick one when the project starts and never revisit it, then spend hours working around its limits — polyfilling matchMedia, faking layout measurements, stubbing APIs a browser would simply provide — or pay browser start-up costs for tests that never touch anything a simulated DOM lacks. This guide compares the three on the things that decide the choice: fidelity, speed, and debugging. It shows how to assign an environment per file or per project so each test runs in the cheapest environment that is still honest. It belongs to Vitest configuration and setup.

Root Cause Analysis

The core trade-off is fidelity against cost. jsdom and happy-dom implement the DOM tree, events and much of the web platform in JavaScript, running in the same Node process as the test. That makes them fast to start and easy to debug, but they do not lay anything out: every element has zero width and height, getBoundingClientRect returns zeros, CSS is parsed only partially, and scrolling, intersection and resize observation do not happen on their own. Tests that depend on any of that either need stubs, which test the stub rather than the component, or a real browser.

The two simulated environments differ from each other too. happy-dom is usually noticeably faster to set up and run, which matters in suites with thousands of files, but it implements fewer APIs and some behaviours differently; a suite that passes in jsdom can fail in happy-dom on edge cases involving forms, selection or less common events. jsdom is slower but closer to the specifications in the areas it covers.

Browser mode removes the simulation question entirely — the DOM, layout, CSS and APIs are real — at the cost of starting a browser, running tests in a page rather than in Node, and a slightly different debugging experience. For components whose behaviour depends on layout, focus or real CSS, that fidelity is worth far more than the start-up time.

The three environments compared happy-dom is the fastest with the narrowest API coverage. jsdom is slower with broader, more specification-faithful coverage but no layout. Browser mode is the slowest to start but provides real layout, CSS and web APIs. happy-dom fastest start and run narrower API coverage no layout jsdom moderate speed broad, faithful coverage no layout browser mode slowest to start real APIs and CSS real layout and focus
The right answer for a whole suite is usually "more than one", assigned by what each test depends on.

Reproducible Setup

A Vitest workspace with three projects: pure logic in Node, most components in jsdom, and layout-dependent components in browser mode.

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    projects: [
      { extends: true, test: { name: 'unit', environment: 'node', include: ['src/**/*.test.ts'] } },
      {
        extends: true,
        test: {
          name: 'dom',
          environment: 'jsdom',
          include: ['src/**/*.test.tsx'],
          exclude: ['src/**/*.browser.test.tsx'],
          setupFiles: ['./vitest.setup.ts'],
        },
      },
      {
        extends: true,
        test: {
          name: 'browser',
          include: ['src/**/*.browser.test.tsx'],
          browser: { enabled: true, provider: 'playwright', headless: true, instances: [{ browser: 'chromium' }] },
        },
      },
    ],
  },
});

Implementation

Step 1 — Default components to a simulated DOM. Most component behaviour — rendering, events, state, accessibility roles — is well covered by jsdom and runs quickly. Keep this the default so new tests are cheap unless they need more.

Step 2 — Move layout-dependent tests to browser mode. A virtualised list that renders rows based on its measured height cannot be tested honestly in jsdom, where the height is always zero.

// src/list/VirtualList.browser.test.tsx
import { render } from 'vitest-browser-react';
import { page } from '@vitest/browser/context';

test('renders only the rows that fit in the viewport', async () => {
  await page.viewport(800, 400);
  const screen = render(<VirtualList rowHeight={40} items={makeItems(1000)} />);
  await expect.element(screen.getByText('Item 1')).toBeVisible();
  const rows = screen.container.querySelectorAll('[role="row"]');
  expect(rows.length).toBeLessThan(20); // about 400 / 40 plus overscan
});

Step 3 — Use per-file overrides for the odd exception. A docblock switches a single file’s environment without moving it to another project, useful when trying happy-dom on part of the suite.

/**
 * @vitest-environment happy-dom
 */
test('formats the badge label', () => { /* … */ });
Deciding which environment a test needs Tests with no DOM run in Node. Tests needing the DOM but not layout, real CSS or unsupported APIs run in jsdom or happy-dom. Tests that depend on measured sizes, scrolling, observers, real focus behaviour or CSS run in browser mode. no DOM at all formatters, reducers validation schemas node DOM, no layout forms, events, roles state and data jsdom or happy-dom layout or real CSS sizes, scrolling observers, focus browser mode
Asking "what does this test depend on?" usually settles the environment in seconds.

Step 4 — Measure before switching jsdom to happy-dom. Run the DOM project in both environments and compare time and failures. A speed gain that comes with dozens of failures needing workarounds is rarely worth it.

npx vitest run --project dom --reporter=dot                        # jsdom baseline
npx vitest run --project dom --environment happy-dom --reporter=dot # candidate

Step 5 — Treat stubs as a signal. Each polyfill added to the setup file — ResizeObserver, IntersectionObserver, matchMedia, scrollIntoView — marks a place where the simulated DOM falls short. A few are fine; a long list means the tests relying on them probably belong in browser mode, where no stub is needed. See mocking matchMedia for responsive component tests for doing the stubbing well when it is justified.

Step 6 — Keep browser tests few and focused. Browser mode costs a browser launch per worker and runs slower per test. Reserve it for behaviour only a browser provides, and keep business logic out of those files.

Step 7 — Name the rule, not just the config. A configuration file tells the runner where tests go; it does not tell engineers how to decide. Write the rule down in a sentence the team can apply without opening the config: “component tests default to jsdom; a test that measures size, scrolls, observes intersection or resize, or depends on real CSS goes in a .browser.test.tsx file”. A clear rule prevents the two failure modes that tend to follow a mixed setup — everything drifting into the browser project because it feels safer, or layout-dependent tests staying in jsdom with ever more elaborate stubs because moving them feels like effort.

Revisit the decision when the tools change. Simulated DOMs gain APIs over time, and browser mode start-up gets cheaper; a stub that was necessary two years ago may now be unnecessary, and a test that once had to run in a browser might run honestly in jsdom today. A yearly look at the setup file’s polyfills and the browser project’s file list is usually enough to keep each test in the cheapest environment that still tells the truth about the component.

It also helps to report timings per project in the CI summary, so the cost of each environment is visible to everyone rather than only to whoever maintains the configuration. When the numbers are in front of the team, decisions about moving tests between projects become quick and evidence-based instead of a matter of habit.

Verification

Check that the projects pick up the intended files and environments by listing them.

npx vitest list --project browser
npx vitest run --project unit --project dom --project browser --reporter=verbose

To confirm the browser project is really exercising layout, temporarily move the virtual list test into the jsdom project. It must fail — every row reports zero height, so the list either renders nothing or everything. That failure is the evidence that the test belongs in a real browser.

Finally, record suite timings per project in CI. If the browser project grows faster than the others, review which of its tests genuinely need a browser; moving even a handful back to jsdom keeps the overall suite quick.

Where each environment's cost comes from Simulated DOMs cost setup time per file and hidden cost in stubs that may drift from real behaviour. Browser mode costs browser start-up per worker and slower individual tests, but needs no stubs for platform APIs. simulated DOM environment setup per file stubs that may drift browser mode browser start per worker no platform stubs needed
Stubs are a hidden cost: they are maintained by hand and can pass while the real API behaves differently.

Troubleshooting

Symptom: “ResizeObserver is not defined” in jsdom. Diagnosis: jsdom does not implement it. Fix: stub it in the setup file if the test does not depend on real sizes; otherwise move the test to browser mode.

Symptom: tests pass in jsdom but fail in happy-dom. Diagnosis: an API is missing or behaves differently. Fix: pin those files to jsdom with a docblock, or keep jsdom for the project if the list is long.

Symptom: browser-mode tests cannot find modules that use Node APIs. Diagnosis: tests run in the page, where fs and path do not exist. Fix: keep Node-only code out of browser test imports, and use Vitest’s browser commands for anything that must run on the server side.

Symptom: browser mode is slow in CI. Diagnosis: each worker launches its own browser, and the runner may lack cached browser binaries. Fix: cache the Playwright browsers directory between runs and limit browser-project concurrency to the runner’s cores.

FAQ

Should new projects start with browser mode for everything?

It is a defensible choice for small component libraries where fidelity matters most. For applications with large suites, a simulated DOM default with a browser project for layout-dependent tests is usually faster overall.

Is happy-dom safe for production suites?

Many teams use it successfully. Measure on your own suite: the speed gain varies, and so does the number of differences you need to work around.

Can one test file run in both environments?

Not directly, but a shared test body imported by two thin files — one per project — can run the same assertions in both, which is a useful way to evaluate a migration.

How does this relate to Playwright component testing?

Both run components in real browsers. Vitest browser mode keeps the Vitest API and configuration; Playwright component testing uses Playwright’s runner and fixtures. See Playwright component testing.