Skip to main content

Guide

happy-dom vs jsdom vs linkedom 2026

Compare happy-dom, jsdom, and linkedom for DOM simulation in JavaScript testing 2026. Speed benchmarks, compatibility, Vitest defaults, and when to use each.

·PkgPulse Team·
0

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

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.

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.