happy-dom vs jsdom vs linkedom: DOM Simulation for Testing 2026
happy-dom vs jsdom vs linkedom: DOM Simulation for Testing in 2026
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
Looking for testing package data? Check PkgPulse's comparison of jsdom and happy-dom for live health scores, download trends, and maintenance analysis.