5 min read - Last updated

BroadcastChannel in Vitest vm pools: when test environments hide Node globals

A reading of why BroadcastChannel is undefined under happy-dom in vm pools, how it breaks real-world libraries, and what the bridging pattern teaches about test isolation.

Live issue state: Open

Observed at analysis: Open

Issue context

The vitest-dev/vitest repository — the test framework — received a bug report about BroadcastChannel being undefined when using the happy-dom environment under vm-based pools (vmThreads and vmForks), while the same test passes under thread-based pools (threads). The issue was open at the time of this analysis on 2026-09-23.

The test is one assertion: expect(typeof BroadcastChannel).toBe('function'). It passes under pool: 'threads' and fails under pool: 'vmThreads' or pool: 'vmForks'.

The real-world impact is substantial: msw/node constructs a BroadcastChannel at import time for its WebSocket interceptor. Every test file that imports msw/node fails to load under a vm pool with ReferenceError: BroadcastChannel is not defined — the reporter had 226 of 560 test files affected.

Knowledge required

This reading assumes familiarity with:

  • Vitest pools: Vitest offers three pool implementations — threads (worker_threads), forks (child_process), and their vm-counterparts vmThreads and vmForks which run each test file in an isolated V8 context (vm.createContext) instead of a fresh worker. The vm pools provide stronger isolation at the cost of some Node built-in visibility.
  • Test environments: Vitest environments (node, jsdom, happy-dom) control the global scope a test runs in. happy-dom provides a browser-like Window object with DOM APIs but no real browser engine.
  • Node.js BroadcastChannel: A Node 18+ global for cross-context message passing, backed by worker_threads. It is available as globalThis.BroadcastChannel in Node but not part of any browser standard that happy-dom implements.
  • The setupVM lifecycle hook: When Vitest runs a vm pool, it calls the environment's setupVM to create the VM context's global scope. This is where environment-specific globals are injected.

Investigation map

Reproduce the issue

The reporter provides a minimal reproduction repository. To reproduce:

git clone https://github.com/iKyi/vitest-vm-repros.git
cd vitest-vm-repros/broadcastchannel
npm install
npx vitest run --pool=threads  # passes
npx vitest run --pool=vmThreads  # fails: expected 'undefined' to be 'function'
npx vitest run --pool=vmForks    # same failure

The test file is a single assertion: expect(typeof BroadcastChannel).toBe('function').

Why it behaves differently

Under threads, Vitest runs each test file in a worker thread. The happy-dom environment's setup() function layers happy-dom globals over Node's globalThis. Because happy-dom does not define BroadcastChannel, Node's built-in BroadcastChannel remains visible through the prototype chain — the test sees typeof BroadcastChannel === 'function'.

Under vmThreads or vmForks, Vitest creates an isolated V8 sandbox with vm.createContext(). The sandbox's global object is happy-dom's Window itself, not Node's globalThis. The happy-dom Window has no BroadcastChannel property (tracked upstream as capricorn86/happy-dom#1920), so the test sees typeof BroadcastChannel === 'undefined'.

The same test file therefore produces different results depending only on the pool choice.

The fix approaches

Three pull requests were opened around the same time, each using the same core pattern:

  1. PR #11331 (closed): "fix(env): bridge BroadcastChannel in happy-dom setupVM" — assigns BroadcastChannel from Node's global onto the happy-dom Window inside setupVM.
  2. PR #11332 (closed): "fix(env): bridge BroadcastChannel in happy-dom (fix #11328)" — same bridging pattern, noting that setupVM already bridges Buffer and structuredClone the same way.
  3. PR #11334 (open at analysis): "fix(happy-dom): bridge BroadcastChannel in vm pools" — the same fix with a dedicated test that calls the environment's setupVM directly and asserts the vm context exposes Node's BroadcastChannel.

The bridging code is:

if (typeof BroadcastChannel !== 'undefined' && !win.BroadcastChannel) {
  win.BroadcastChannel = BroadcastChannel;
}

This exactly mirrors the existing bridges for Buffer and structuredClone in setupVM, showing a well-established pattern for vm-compatibility patches.

Hypothesis: testing the fix

The fix was not verified locally for this reading. The PRs describe a successful verification: the environment's test suite (39 tests) passes, and a dedicated test that calls setupVM and asserts typeof context.BroadcastChannel === 'function' fails before the patch and passes after it.

Transferable concepts

Test pool isolation models

The issue illuminates a fundamental architectural choice in modern test frameworks. Thread and process pools create a fresh Node.js runtime per file, preserving access to built-in globals. VM pools create a sandboxed JavaScript context, which offers stronger isolation but requires explicit bridging of any Node API that the test environment does not implement. This trade-off appears in Vitest and also in Jest's vm configuration.

The bridging pattern

When a library or environment wraps the JavaScript global scope (as happy-dom, jsdom, and other browser simulators do), any API the wrapper does not implement is simply absent. The standard fix is a conditional bridge: check whether the API exists in the outer scope and the inner scope lacks it, then assign it. Vitest's own setupVM already does this for Buffer and structuredClone. This pattern is reusable anywhere you compose scopes — in polyfill code, sandboxed evaluation, and plugin systems.

Real-world failure cascades

A missing global in a test environment can cascade through the dependency graph. msw/node imports and constructs a BroadcastChannel at module evaluation time. That single missing global blocks every test file that transitively imports msw/node from even loading, not from running — the error is a ReferenceError at import time. Understanding which of your dependencies have side effects at import time (and which Node globals they depend on) is essential when switching pool types.

Sources

Independent reading

This Note is an independent reading of a public GitHub issue. It is not official project guidance, does not propose or promise a fix, and does not speak for the vitest-dev/vitest project or its maintainers. The Issue State is historical (observed during analysis) and the Note remains a draft.