<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/happy-dom-vs-jsdom-vs-linkedom-dom-simulation-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/happy-dom-vs-jsdom-vs-linkedom-dom-simulation-2026/raw.md -->
<!-- Source path: content/guides/happy-dom-vs-jsdom-vs-linkedom-dom-simulation-2026.mdx -->

---
og_image: "/images/guides/happy-dom-vs-jsdom-vs-linkedom-dom-simulation-2026.webp"
title: "happy-dom vs jsdom vs linkedom 2026"
description: "Compare happy-dom, jsdom, and linkedom for DOM simulation in JavaScript testing 2026. Speed benchmarks, compatibility, Vitest defaults, and when to use each."
date: "2026-03-09"
authors: ["team"]
tier: 2
tags: ["testing", "javascript", "vitest", "jest", "npm"]
---

## 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 `browser` mode in new projects, but `happy-dom` remains 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.

```bash
npm install --save-dev jest-environment-jsdom
# or if using Vitest:
npm install --save-dev jsdom
```

**Vitest configuration with jsdom:**

```typescript
// 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):**

```javascript
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['@testing-library/jest-dom'],
}
```

**What jsdom covers well:**

```typescript
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.location` manipulation 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.

```bash
npm install --save-dev happy-dom
```

**Vitest configuration with happy-dom:**

```typescript
// 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):

```typescript
// 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:**

```typescript
// 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:**

```typescript
// ❌ 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.

```bash
npm install linkedom
```

```typescript
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.

```typescript
// ❌ 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:

```typescript
// 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:

1. **ResizeObserver**: Add a polyfill in your setup file:
   ```typescript
   // test-setup.ts
   global.ResizeObserver = class ResizeObserver {
     observe() {}
     unobserve() {}
     disconnect() {}
   }
   ```

2. **CSS media queries**: happy-dom doesn't fully support `window.matchMedia`. Polyfill it:
   ```typescript
   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(),
     }),
   })
   ```

3. **Specific failing tests**: Use the `@vitest-environment jsdom` comment 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](https://www.pkgpulse.com/packages/jsdom) and [happy-dom](https://www.pkgpulse.com/packages/happy-dom) for live health scores, download trends, and maintenance analysis.*

*Compare happy-dom and jsdom package health on [PkgPulse](https://www.pkgpulse.com/compare/happy-dom-vs-jsdom).*

*Related: [Best JavaScript Testing Frameworks Compared (2026)](/guides/best-javascript-testing-frameworks-2026), [Bun Test vs Vitest vs Jest 2026: Speed Compared](/guides/bun-test-vs-vitest-vs-jest-2026), [The Consolidation of JavaScript Testing: How Vitest Won](/guides/consolidation-javascript-testing-how-vitest-won-2026).*
