TL;DR
For DOM simulation in JavaScript testing, happy-dom is the fastest option and Vitest's recommended default (5–10× faster than jsdom), jsdom remains the most compatible and battle-tested choice (Jest's default, 25M+ weekly downloads), and linkedom is a third option focused on server-side rendering scenarios. For Vitest users, switch to happy-dom unless you're hitting compatibility issues. For Jest users, jsdom is still the standard and there's no urgent migration reason.
Key Takeaways
- happy-dom is 5–10× faster than jsdom in Vitest benchmarks and is now Vitest's recommended environment
- jsdom has superior API coverage and handles edge cases better — it's the safe choice when test reliability matters more than speed
- linkedom is optimized for server-side rendering, not testing — it's faster than jsdom for parsing but incomplete for DOM interaction tests
- Vitest 2.x defaults to
browsermode in new projects, buthappy-domremains the standard for unit tests with DOM access - jest-environment-happy-dom lets Jest users migrate without switching test runners
- React Testing Library works with both happy-dom and jsdom — no library changes needed when switching
Why DOM Simulation Matters for Test Speed
When you run npm test on a React component, Node.js doesn't have a browser DOM. Libraries like happy-dom, jsdom, and linkedom provide a JavaScript implementation of browser APIs so tests can run in Node.js without launching Chrome.
The performance difference matters more than it sounds. A test suite with 500 component tests might complete in:
- happy-dom: 8 seconds
- jsdom: 45 seconds
- Real browser (Playwright): 3 minutes
For a team running tests on every PR commit, the difference between 8 and 45 seconds compounds into hours of developer time per week.
The trade-off: speed comes at the cost of API completeness. happy-dom implements a subset of browser APIs. jsdom implements more, but still isn't a complete browser. For browser-accurate testing, Playwright's component testing is the correct tool.
jsdom: The Battle-Tested Standard
npm: jsdom | weekly downloads: 25M+ | bundle size: ~3.5MB | default in: Jest
jsdom is the oldest and most widely deployed DOM simulation library. It's been the default environment for Jest since Jest's creation, which explains its massive download numbers.
npm install --save-dev jest-environment-jsdom
# or if using Vitest:
npm install --save-dev jsdom
Vitest configuration with jsdom:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true, // makes describe, it, expect available globally
setupFiles: ['./src/test-setup.ts'],
},
})
Jest configuration (jsdom is the default):
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['@testing-library/jest-dom'],
}
What jsdom covers well:
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
test('button handles click events', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
const button = screen.getByRole('button', { name: 'Click me' })
fireEvent.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
test('form submission with jsdom', () => {
const onSubmit = vi.fn(e => e.preventDefault())
render(
<form onSubmit={onSubmit}>
<input type="text" placeholder="Name" />
<button type="submit">Submit</button>
</form>
)
const input = screen.getByPlaceholderText('Name')
fireEvent.change(input, { target: { value: 'John' } })
fireEvent.click(screen.getByRole('button'))
expect(onSubmit).toHaveBeenCalledTimes(1)
})
jsdom's strengths:
- Widest CSS selector support
- Most complete event handling (including keyboard navigation)
- Best compatibility with React Testing Library
- Handles complex DOM mutations and MutationObserver correctly
- Supports navigation (history API, hash routing)
window.locationmanipulation works correctly
jsdom's limitations:
- Slow startup and execution (heavy implementation)
- No CSS layout (computed styles are usually empty or wrong)
- No canvas 2D context (unless you add jest-canvas-mock)
- No WebGL
- No real network requests (must mock
fetch)
happy-dom: Speed-First Implementation
npm: happy-dom | weekly downloads: ~3M | bundle size: ~800KB | default in: Vitest (recommended)
happy-dom was built specifically as a faster alternative to jsdom for testing. Its implementation prioritizes the subset of APIs used in 95% of component tests while skipping slower, less-used paths.
npm install --save-dev happy-dom
Vitest configuration with happy-dom:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom', // recommended for Vitest
globals: true,
},
})
File-level environment override (when you need jsdom for specific tests):
// ComplexComponent.test.tsx
// @vitest-environment jsdom ← overrides global happy-dom for this file
import { render, screen } from '@testing-library/react'
This lets you use happy-dom globally (for speed) and fall back to jsdom for tests that need deeper compatibility.
Speed comparison (Vitest benchmark, 100 component tests):
| Environment | Time | Relative |
|---|---|---|
| happy-dom | 4.2s | 1× |
| jsdom | 31.5s | 7.5× slower |
| @playwright/test (browser) | 8.1s | 2× slower (but real browser!) |
The benchmark varies by test complexity. Simple render-and-query tests see the biggest happy-dom advantage. Tests with complex event handling or CSS layout tend to narrow the gap.
Where happy-dom shines:
// Fast component tests — happy-dom handles this perfectly
test('counter increments', async () => {
render(<Counter initialCount={0} />)
const button = screen.getByRole('button', { name: '+' })
await userEvent.click(button)
await userEvent.click(button)
expect(screen.getByText('2')).toBeInTheDocument()
})
test('form validation shows errors', async () => {
render(<LoginForm />)
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Password is required')).toBeInTheDocument()
})
test('dropdown opens on click', async () => {
render(<Select options={['React', 'Vue', 'Svelte']} />)
await userEvent.click(screen.getByRole('combobox'))
expect(screen.getByRole('listbox')).toBeInTheDocument()
expect(screen.getAllByRole('option')).toHaveLength(3)
})
Where happy-dom has gaps:
// ❌ CSS computed styles (almost never correct in any DOM simulator)
const el = document.querySelector('.my-element')
window.getComputedStyle(el).display // unreliable in happy-dom AND jsdom
// ❌ ResizeObserver (may need polyfill)
// ❌ Some canvas operations
// ❌ Complex navigation scenarios (use jsdom or Playwright)
// ❌ CSS custom properties affecting layout
React Testing Library compatibility: happy-dom works with @testing-library/react without any changes. The library uses standard DOM APIs that happy-dom implements correctly.
linkedom: Server-Side Rendering Focused
npm: linkedom | weekly downloads: ~850K | bundle size: ~250KB | use case: SSR/HTML parsing
linkedom takes a different approach: it's built around a linked list DOM structure (hence the name) that makes serialization (innerHTML, outerHTML) extremely fast. It's the right tool for server-side HTML generation, not for testing interactive components.
npm install linkedom
import { parseHTML } from 'linkedom'
// Fast for parsing and serialization
const { document, window } = parseHTML(`
<!doctype html>
<html>
<head><title>Test</title></head>
<body>
<div id="app">
<h1>Hello World</h1>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</div>
</body>
</html>
`)
// Traverse and query
const items = document.querySelectorAll('li')
console.log(items.length) // 2
// Modify and serialize
const app = document.getElementById('app')
app.appendChild(document.createElement('p')).textContent = 'Added!'
console.log(document.body.innerHTML) // Serializes fast
Where linkedom is used:
- Cloudflare Workers HTML rewriting (faster than Cloudflare's HTMLRewriter for complex transforms)
- SSR frameworks that need fast HTML parsing on the edge
- Web scraping in serverless environments where Puppeteer is too heavy
- Testing HTML templates without full browser simulation needs
Why linkedom isn't ideal for component testing:
linkedom doesn't implement event propagation, JSDOM-compatible MutationObserver, or accurate element focus/blur behaviors. Testing library utilities like fireEvent and userEvent from @testing-library will produce unreliable results.
// ❌ Don't use linkedom for this
import { parseHTML } from 'linkedom'
const { document } = parseHTML('<button onclick="this.classList.add(\'clicked\')">Click</button>')
const button = document.querySelector('button')
button.click() // Events don't propagate correctly
Comparison Table
| Feature | jsdom | happy-dom | linkedom |
|---|---|---|---|
| Weekly downloads | 25M | 3M | 850K |
| Speed vs jsdom | 1× | 5–10× faster | 3× faster (parsing) |
| Jest default | ✅ | ✗ | ✗ |
| Vitest recommended | ✗ | ✅ | ✗ |
| CSS selectors | Complete | Good | Good |
| Event handling | Complete | Good | Limited |
| MutationObserver | ✅ | ✅ | ⚠️ |
| ResizeObserver | ✅ | ⚠️ (polyfill needed) | ❌ |
| Canvas mock support | Via plugin | ✅ | ❌ |
| Navigation/history | ✅ | ✅ | ❌ |
| RTL compatibility | ✅ | ✅ | ❌ |
| SSR/HTML parsing | Capable | Capable | ✅ Excellent |
| Bundle size | ~3.5MB | ~800KB | ~250KB |
| Primary use case | Component testing | Fast component testing | HTML parsing/SSR |
Migration: jsdom → happy-dom in Vitest
For Vitest users, the migration is typically one line:
// Before (vitest.config.ts)
export default defineConfig({
test: { environment: 'jsdom' }
})
// After
export default defineConfig({
test: { environment: 'happy-dom' }
})
Run your test suite. If all tests pass, you're done. If some fail, check:
-
ResizeObserver: Add a polyfill in your setup file:
// test-setup.ts global.ResizeObserver = class ResizeObserver { observe() {} unobserve() {} disconnect() {} } -
CSS media queries: happy-dom doesn't fully support
window.matchMedia. Polyfill it:Object.defineProperty(window, 'matchMedia', { writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), }), }) -
Specific failing tests: Use the
@vitest-environment jsdomcomment to opt specific test files back to jsdom without changing the global config.
When to Use Each
Use jsdom if:
- You're on Jest (it's the default, no reason to change)
- Test reliability is more important than speed
- You have complex DOM interaction tests with keyboard navigation, focus management, or media queries
- You're testing forms with complex browser-specific behavior
Use happy-dom if:
- You're on Vitest (it's the recommended choice)
- Test speed is a priority (CI time, developer feedback loops)
- Your tests are primarily render-and-query patterns
- You want the most actively developed DOM simulator
Use linkedom if:
- You're parsing HTML on the server or edge (not testing)
- You need fast HTML rewriting in Cloudflare Workers
- You're building an SSR renderer that needs fast DOM serialization
- You're doing web scraping in a Node.js environment without Puppeteer
Use Playwright component testing if:
- You need real browser APIs (canvas, WebGL, actual CSS layout)
- You're testing visual appearance (screenshots)
- Integration-level testing that should match real browser behavior exactly
Methodology
- npm download data from npmjs.com (March 2026)
- Speed benchmarks from Vitest's official documentation and community benchmarks
- API compatibility from each library's test suite and issue trackers
- Vitest default recommendation from vitest.dev environment documentation
- React Testing Library compatibility from the official RTL docs
Choosing the Right Environment for Your Test Suite
One practical consideration teams overlook when switching DOM simulators is the interaction with TypeScript's lib compiler option. Both happy-dom and jsdom inject globals (window, document, navigator) into the Node.js test process, but the TypeScript type definitions come from @types/jsdom for jsdom and from happy-dom's own bundled types. If your tsconfig.json includes "lib": ["DOM"], you are already pulling in browser type definitions — the question is just whether the runtime implementation matches those types at test time. Happy-dom tracks the TypeScript DOM types closely, but occasional gaps appear in newer browser APIs before happy-dom has implemented them. When a test fails with a "not implemented" error rather than an assertion error, that is a happy-dom coverage gap — the fix is either a polyfill in your setup file or a per-file environment override to jsdom. Tracking these gaps with a comment linking to the happy-dom GitHub issue is a good practice for teams maintaining a large test suite across both environments.
Looking for testing package data? Check PkgPulse's comparison of jsdom and happy-dom for live health scores, download trends, and maintenance analysis.
Compare happy-dom and jsdom package health on PkgPulse.
Related: Best JavaScript Testing Frameworks Compared (2026), Bun Test vs Vitest vs Jest 2026: Speed Compared, The Consolidation of JavaScript Testing: How Vitest Won.