Skip to main content

happy-dom vs jsdom vs linkedom: DOM Simulation for Testing 2026

·PkgPulse Team

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

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

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):

EnvironmentTimeRelative
happy-dom4.2s
jsdom31.5s7.5× slower
@playwright/test (browser)8.1s2× 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

Featurejsdomhappy-domlinkedom
Weekly downloads25M3M850K
Speed vs jsdom5–10× faster3× faster (parsing)
Jest default
Vitest recommended
CSS selectorsCompleteGoodGood
Event handlingCompleteGoodLimited
MutationObserver⚠️
ResizeObserver⚠️ (polyfill needed)
Canvas mock supportVia plugin
Navigation/history
RTL compatibility
SSR/HTML parsingCapableCapable✅ Excellent
Bundle size~3.5MB~800KB~250KB
Primary use caseComponent testingFast component testingHTML 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:

  1. ResizeObserver: Add a polyfill in your setup file:

    // test-setup.ts
    global.ResizeObserver = class ResizeObserver {
      observe() {}
      unobserve() {}
      disconnect() {}
    }
    
  2. 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(),
      }),
    })
    
  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

Looking for testing package data? Check PkgPulse's comparison of jsdom and happy-dom for live health scores, download trends, and maintenance analysis.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.