Skip to main content

happy-dom vs jsdom (2026): Which Is Faster for Vitest?

·PkgPulse Team
0

TL;DR

happy-dom is 2-4x faster than jsdom and works great for most React/Vue component tests with Vitest. jsdom (~27M weekly downloads) has been the standard DOM simulation environment for 10+ years — more complete, more battle-tested. happy-dom (~3M downloads) sacrifices edge-case completeness for speed. For Vitest users with large test suites, happy-dom is worth the switch. For Jest or tests that rely on obscure DOM APIs, stick with jsdom.

Key Takeaways

  • jsdom: ~27M weekly downloads — happy-dom: ~3M (npm, March 2026)
  • happy-dom is 2-4x faster — significant for large test suites
  • jsdom has better DOM compatibility — more complete browser API support
  • happy-dom is Vitest's recommended default — Vitest docs suggest happy-dom
  • Both simulate a browser environment — neither runs a real browser (use Playwright for that)

What These Libraries Do

Both jsdom and happy-dom implement the browser DOM and Web APIs in Node.js, allowing component tests to run without a real browser:

Test runner (Node.js)
  ↓
DOM simulation (jsdom OR happy-dom)
  ↓
Your React/Vue component
  ↓
Rendered virtual DOM
  ↓
Assertions via Testing Library

Neither is a real browser — they are JavaScript approximations of browser APIs. For true cross-browser testing or tests that depend on real CSS rendering, layout, and paint behavior, use Playwright or Cypress against a real browser. jsdom and happy-dom exist to make component unit tests fast and runnable in Node.


Performance Benchmark

The speed difference between happy-dom and jsdom is substantial enough to matter on any real project. The gap comes from happy-dom's design philosophy: implement browser APIs with a focus on the common path, sacrificing edge-case spec compliance for speed. jsdom aims for much higher spec compliance, which requires more complex bookkeeping on every DOM operation.

For a test suite with 500 React component tests using React Testing Library:

Test suite: 500 React component tests (React Testing Library)

Environment   Time      vs happy-dom
-----------   --------  -------------
happy-dom     ~18s      baseline
jsdom         ~45s      ~2.5x slower

Test suite: 100 simple component tests

Environment   Time      vs happy-dom
-----------   --------  -------------
happy-dom     ~4s       baseline
jsdom         ~10s      ~2.5x slower

The speedup compounds with test suite size. A team running 2,000 component tests sees the biggest gains — the difference between a 3-minute CI run and a 7-minute CI run is meaningful for developer iteration speed.

The speedup comes from two sources: happy-dom initializes its DOM environment faster per test file (important when you have many test files), and it processes DOM mutations more quickly during test execution. jsdom's thorough event propagation model and comprehensive CSS support add overhead even when tests don't use those features.


Configuration

Switching environments in Vitest is a one-line change globally, or a per-file comment for surgical control:

// vitest.config.ts — global environment setting
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'happy-dom', // Switch here: 'happy-dom' | 'jsdom' | 'node'
    setupFiles: ['./src/test/setup.ts'],
  },
});
// src/test/setup.ts — works with both environments
import '@testing-library/jest-dom';
// Adds toBeInTheDocument, toHaveValue, toBeVisible, etc.

For per-file overrides, add a comment at the very top of the test file:

// src/components/chart.test.tsx
// @vitest-environment jsdom
// This file uses complex CSS and needs jsdom's more complete implementation

import { render, screen } from '@testing-library/react';
import { ChartComponent } from './ChartComponent';

test('renders chart with correct labels', () => {
  render(<ChartComponent data={[1, 2, 3]} />);
  expect(screen.getByRole('img', { name: /chart/i })).toBeInTheDocument();
});

For Jest users, happy-dom requires a separate package:

// jest.config.js
module.exports = {
  // Default: 'node'
  // Standard: testEnvironment: 'jsdom'
  // happy-dom for Jest:
  testEnvironment: 'jest-environment-happydom',
};

Vitest ships with both happy-dom and jsdom built in — no additional installation needed for either. Jest requires explicit setup for either non-default environment.


API Compatibility Gaps

Where happy-dom diverges from real browser behavior based on known issues and community reports:

CSS getComputedStyle() — happy-dom's computed style implementation is incomplete. It handles inline styles and basic CSS properties well, but cascaded styles from stylesheets, CSS custom properties via getPropertyValue(), and computed values for layout properties (like display: flex effects on children) may return incorrect or empty values.

window.location behaviors — Some navigation patterns, particularly involving history.pushState and the interaction between URL updates and the location object, have known edge cases in happy-dom that behave differently from real browsers or jsdom.

IntersectionObserver — Both happy-dom and jsdom implement stub versions of IntersectionObserver that do not actually perform intersection calculations. If your component logic depends on real intersection detection, you'll need to mock it regardless of which environment you use.

Shadow DOM — happy-dom's Shadow DOM support is improving but lags behind jsdom's more complete implementation. Components that rely heavily on custom elements and Shadow DOM encapsulation may see failures with happy-dom.

Canvas API — Both environments have limited Canvas support. canvas.getContext('2d') returns an object, but drawing methods and pixel data APIs are stubs. For Canvas-heavy component tests, neither environment is a substitute for a real browser.

// APIs both support well — the common path for React/Vue tests
document.createElement('div');
document.querySelector('.class');
element.addEventListener('click', handler);
window.localStorage.setItem('key', 'value');
fetch('https://api.example.com'); // via msw mocking
window.dispatchEvent(new CustomEvent('resize'));
new MutationObserver(callback).observe(target, config);
new ResizeObserver(callback).observe(element);

// APIs where jsdom has better support
// - Complex CSS cascade and computed styles
// - SVG manipulation (partial in happy-dom)
// - CSS custom properties via getComputedStyle
// - Some window.location edge cases
// - Shadow DOM (improving in happy-dom)

In practice, most React and Vue component tests that use React Testing Library or Vue Testing Library do not hit these gaps. The Testing Library philosophy of testing from the user's perspective — clicking, typing, reading accessible labels — maps well to what both environments support.


Migration Checklist

Switching from jsdom to happy-dom takes 15 minutes for most projects. Here's the process:

Step 1: Update vitest.config.ts

// Before
environment: 'jsdom'

// After
environment: 'happy-dom'

Step 2: Run your test suite

pnpm vitest run

Step 3: Triage failures

Most failures fall into two categories:

  • Real failures you hadn't caught — bugs in your components that jsdom was silently ignoring. Fix these normally.
  • happy-dom API gaps — tests that fail because happy-dom doesn't support a specific API your test uses.

For the second category, the fix is usually a per-file environment override:

// Add to the top of the affected test file
// @vitest-environment jsdom

Step 4: Add targeted mocks for common APIs

Some mocks are needed in both environments but are often added during migration:

// src/test/setup.ts — common browser API mocks
import '@testing-library/jest-dom';

// matchMedia — not implemented in either environment
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

// scrollTo — stubbed in both environments
window.scrollTo = vi.fn();

Step 5: Accept the hybrid approach

The goal is not 100% happy-dom. The pragmatic outcome is: most tests run on happy-dom (fast), a small set of tests that need deeper DOM compatibility run on jsdom (via per-file comment). This hybrid approach is explicitly supported by Vitest and is the recommended pattern in the docs.

A typical outcome after migration: 95% of test files on happy-dom, 5% still on jsdom — with an overall suite time reduction of 40-60%.


Package Health

PackageWeekly DownloadsMaintainers
jsdom~27MDomenic Denicola + community
happy-dom~3MDavid Konsumer + contributors
jest-environment-jsdom~18MJest team (bundles jsdom)
jest-environment-happydom~400KCommunity

jsdom (~27M weekly downloads) includes all Jest usage via jest-environment-jsdom. Its download count dwarfs happy-dom because Jest's default environment recommendation was jsdom for years. The project is actively maintained with Domenic Denicola (WHATWG spec editor) contributing, which gives it unusually strong browser spec alignment.

happy-dom (~3M downloads) is growing steadily as the Vitest ecosystem matures. David Konsumer and the contributors are active and responsive on GitHub. The project prioritizes speed and pragmatic completeness — the issue tracker is addressed quickly, and API gaps are regularly closed. The 3M number does not include transitive Jest usage, so the active user count is meaningfully different from jsdom's.


When to Choose

Choose happy-dom when:

  • You're using Vitest and want the recommended default environment
  • Your test suite has 200+ component tests where 2-4x speed improvement matters
  • Your tests are standard React, Vue, or Svelte component tests using Testing Library
  • You want faster CI feedback loops without sacrificing meaningful coverage
  • You're willing to add per-file jsdom fallbacks for the small percentage of edge-case tests

Choose jsdom when:

  • You're using Jest, where jsdom is the default with the most documentation and support
  • Your tests depend on complex CSS cascade, computed styles, or CSS custom properties
  • Your components use Shadow DOM, SVG manipulation, or Canvas API
  • You want maximum browser API compatibility and don't want to manage per-file environment overrides
  • Your team is unfamiliar with happy-dom and migration risk outweighs the speed benefit

Testing Library Compatibility and Practical Gotchas

The overwhelming majority of React component tests written with React Testing Library work identically under happy-dom and jsdom. The Testing Library philosophy — query by accessible role, label, or text rather than by CSS selector — means tests rarely depend on the CSS cascade or complex layout properties that are the main area where happy-dom's DOM compliance diverges. If you are using getByRole, getByLabelText, getByText, and userEvent from @testing-library/user-event, you are almost certainly in safe territory with happy-dom.

The gotchas surface in specific scenarios. Components that call window.getComputedStyle() to make rendering decisions — dark mode toggles that read CSS custom properties, responsive components that inspect computed display values — will behave inconsistently under happy-dom. happy-dom returns computed style values for inline styles and simple class-based rules, but the cascade and inheritance model is incomplete. If your component has a hook like const isDark = getComputedStyle(ref.current).getPropertyValue('--color-scheme') === 'dark', that hook will silently return an empty string in happy-dom.

A second common gotcha is third-party components that use ResizeObserver or IntersectionObserver with side effects. Both environments stub these APIs, but the stubbing behavior differs slightly. The safe pattern is to mock them explicitly in your setup file regardless of which environment you use:

// src/test/setup.ts — works for both happy-dom and jsdom
class MockResizeObserver {
  observe() {}
  unobserve() {}
  disconnect() {}
}
global.ResizeObserver = MockResizeObserver as any

This makes the behavior explicit and consistent across environments, rather than relying on the stub implementation of whichever library you're using.

When the Speed Difference Actually Matters

The 2-4x speed advantage of happy-dom is not uniformly distributed across test suites. The speedup is most pronounced in two scenarios: test suites with many small test files (where per-file environment initialization cost dominates), and test suites that render many components per test (where DOM mutation overhead accumulates).

For a team running 50 component tests across 50 files, the per-file initialization savings from happy-dom are significant. jsdom's more thorough initialization sequence — loading CSS parsing machinery, full event propagation infrastructure, and a more complete layout engine stub — runs on every test file even if that file's tests never use CSS or complex events.

The speedup matters less for test suites organized into fewer, larger files where multiple tests run in the same environment instance. In that pattern, initialization cost is amortized across many tests, and the per-mutation overhead becomes the dominant factor. For a 500-test suite organized into 10 large files, the real-world speedup from switching to happy-dom might be closer to 1.5x than 4x.

For teams where CI is already fast (under 60 seconds for the full suite), the switch is a quality-of-life improvement rather than a strategic priority. For teams with 5-minute-plus CI runs blocked on the test step, the switch to happy-dom is one of the highest-leverage performance improvements available with minimal risk.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.