DEV Community

Cover image for Fixing Vitest CI Worker Crashes by Switching from jsdom to happy-dom

Fixing Vitest CI Worker Crashes by Switching from jsdom to happy-dom

Our Vitest suite passed every time locally, but GitHub Actions failed before executing a single test. Instead of failed assertions, every test worker crashed during startup, leaving us with 0% coverage and a broken CI pipeline.

After narrowing the issue down to our test environment, we replaced jsdom with happy-dom. That change resolved the worker crashes in our environment and restored a stable GitHub Actions pipeline.


Environment Context

To help determine if this applies to your setup, here is the stack where we encountered this:

  • Framework: Next.js (App Router)
  • Testing: Vitest, @testing-library/react
  • Package Manager: pnpm (Workspace / Monorepo)
  • CI Environment: GitHub Actions (ubuntu-latest, Node.js 20)

1. The Error: Unhandled Worker Failures in CI

During the pnpm --filter app test:coverage step in GitHub Actions, Vitest failed immediately upon initializing the worker pool:

Vitest caught 7 unhandled errors during the test run.

⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯
Error: [vitest-pool]: Failed to start forks worker for test files /apps/app/__tests__/poll.test.ts.

Caused by: TypeError: webidl.util.markAsUncloneable is not a function
 ❯ new CacheStorage ../node_modules/jsdom/node_modules/undici/lib/web/cache/cachestorage.js:20:17
 ❯ Object.<anonymous> ../node_modules/jsdom/node_modules/undici/index.js:179:25
 ❯ Object.<anonymous> ../node_modules/jsdom/lib/api.js:12:33

 Test Files  no tests
      Tests  no tests
     Errors  7 errors

ERROR: Coverage for lines (0%) does not meet global threshold (80%)

Enter fullscreen mode Exit fullscreen mode

If you encounter errors such as webidl.util.markAsUncloneable is not a function or Failed to start forks worker in a vitest-pool setup, the problem may lie in your test environment's dependency tree rather than in your test code.


2. The Investigation

At first, we assumed one of the test files was failing or throwing an uncaught exception.

But the stack trace told a different story. None of the 7 test files had actually started executing. Every failure occurred while Vitest was initializing worker processes, which meant the problem was happening before our application code even loaded.

The stack trace pointed directly into jsdom's internal undici dependency during worker initialization (jsdom/node_modules/undici/lib/web/cache/cachestorage.js). Rather than a bug in our application code, this suggested an environment-level compatibility issue involving the test environment rather than our application code.

Beyond initial loading issues, jsdom emulates a large portion of standard browser behavior and maintains a detailed DOM object model. In resource-constrained CI environments (like standard Linux runners in GitHub Actions), this extra memory footprint can increase resource usage and startup time, especially during coverage runs.

We could have spent more time investigating the exact dependency mismatch, but since our tests only required standard DOM APIs, switching to a lighter implementation was the simpler and lower-risk solution.


3. The Switch: Moving to happy-dom

Why happy-dom Worked

Unlike jsdom, happy-dom focuses specifically on implementing the browser APIs commonly needed by modern frontend unit tests (such as React synthetic events and DOM queries) rather than reproducing every full browser behavior. Because of its smaller scope, happy-dom generally starts faster and has a lower resource footprint than jsdom for many component-testing scenarios, making it a good fit for React component testing in Vitest.

Step 1: Replace Dependencies

pnpm --filter app remove jsdom
pnpm --filter app add -D happy-dom @vitest/coverage-v8

Enter fullscreen mode Exit fullscreen mode

Step 2: Update Vitest Configuration

Update vitest.config.ts to use happy-dom:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'json-summary'],
      exclude: [
        'node_modules/',
        '.next/',
        'vitest.config.ts',
        'vitest.setup.ts',
        '**/*.d.ts',
      ],
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

Step 3: Recommended Test Setup

In vitest.setup.ts, ensure component trees are explicitly unmounted after each test block:

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();
});

Enter fullscreen mode Exit fullscreen mode

Note: This cleanup setup was not required to fix the worker initialization issue, but we keep it to ensure each test runs with a clean DOM and to avoid state leaking between tests.


4. Results

Metric Before (jsdom) After (happy-dom)
Worker Initialization ❌ Failed across all 7 test files All workers started successfully
Code Coverage ❌ 0% (Threshold failure) Coverage generated successfully
Pipeline Status ❌ CI failed consistently CI passed consistently
Execution ❌ Failed during worker initialization Entire suite finished in ~15 seconds

While we didn't identify the exact package combination that triggered the incompatibility, replacing jsdom removed the issue entirely for our CI environment, making further investigation unnecessary for our use case.


5. When Should You Keep jsdom?

Every tool comes with trade-offs, and happy-dom isn't a 1:1 replacement for every project:

  • Keep jsdom if: Your tests depend on advanced browser APIs that happy-dom doesn't fully implement, or you are testing behavior that closely mirrors browser layout and parsing internals.
  • Use happy-dom if: You are primarily running React, Vue, or Svelte component tests with @testing-library, where rendering components and asserting on DOM output is all you need.

6. Lessons Learned & Takeaways

Key Takeaway: Before changing test code, check whether the failure occurs before the first test executes. If worker initialization fails, the root cause is often the test environment, dependency graph, or runtime—not your application logic.

The lesson wasn't that happy-dom is universally better than jsdom.

It was that your test environment should match your testing needs. For our React component tests, a lightweight DOM implementation was enough—and choosing the simplest test environment that satisfied our requirements made the CI pipeline both faster and more reliable.

Sometimes the quickest path to a stable CI pipeline isn't finding the perfect root cause—it's choosing the simplest tool that satisfies your testing requirements.

Top comments (0)