You Should Stop Using JSDOM in 2025. Here's Why
This week, I was in a situation that I'm sure many frontend developers can relate to. I was writing component tests, relying on my standard stack: Jest and JSDOM. As I worked, a test failed in a way that didn't make sense. The component's code was correct, but the test threw an error that seemed unrelated to my logic.
This sent me down a rabbit hole of research. I started digging through GitHub issues and tech blogs, and a pattern quickly emerged. The strange errors I was seeing weren't bugs in my code; they were symptoms of a fundamental problem with the testing tool itself. I realized that for years, I'd been battling the testing environment, not my own application code.
What I found is critical for every frontend developer to understand: JSDOM can cause your tests to fail even when your component code is perfectly fine. This discovery forces us to ask a tough question: If your testing tool can’t correctly assess valid code, how can you possibly trust it to reliably catch invalid code? What guarantee do we have that it isn’t giving us false positives and letting real bugs slip through?
The Core Problem: JSDOM's Flawed Simulation
JSDOM isn't a browser. It’s an impressive JavaScript implementation of web standards that simulates a browser environment inside Node.js. And that simulation is the root of the problem. It creates a "Franken-environment"—a hybrid that is neither a true browser nor a true Node.js environment.
This mismatch leads to tests that are not just flaky, but fundamentally untrustworthy. But don't just take my word for it. Let's prove it with code.
Proving the Failures in Practice
To demonstrate these issues, I created three simple components and tested them with three tools:
The Cypress and Vitest tests passed every time because they run in a real browser. The JSDOM tests, however, failed on perfectly correct code.
1. The Missing Global: When fetch Disappears
Almost every modern application uses fetch. It’s a standard global API in the browser. Your component is correct for using it.
The Component: FetchCheck/index.tsx
import { useEffect, useState } from 'react';
export const FetchCheck = () => {
const [userName, setUserName] = useState('');
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users/1')
.then((res) => res.json())
.then((data) => setUserName(data.name));
}, []);
return <div>User: {userName}</div>;
};
The JSDOM Test: FetchCheck.test.tsx (Fails Correct Code ❌) This test won't even render the component successfully. It will crash the test runner.
// This test will throw an error during render:
// ReferenceError: fetch is not defined
it('fails because "fetch" is not defined in the environment', () => {
let error;
try {
render(<FetchCheck />);
} catch (e) {
error = e;
}
expect(error).toBeInstanceOf(ReferenceError);
});
Why This Is a Problem: Your component code is valid, but the test fails because JSDOM's environment doesn't provide this standard API. You are forced to install polyfills and "fix" your test setup just to test correct code. This is the first sign your tool is working against you.
The Cypress Test: FetchCheck.cy.tsx (Passes Correct Code ✅)
it('uses the global browser fetch API successfully', () => {
cy.intercept('GET', '**/users/1', {
statusCode: 200,
body: { id: 1, name: 'Cypress User' },
}).as('getUser');
cy.mount(<FetchCheck />);
cy.wait('@getUser');
cy.contains('User: Cypress User').should('be.visible');
});
It just works. Because in a real browser, fetch exists.
2. The API Mismatch: When EventTarget Breaks
This example shows the "Franken-environment" in action. Your component uses standard event APIs. The code is flawless.
The Component: EventCompatibilityCheck/index.tsx
import { useEffect, useState } from 'react';
export const EventCompatibilityCheck = () => {
const [message, setMessage] = useState('Waiting...');
useEffect(() => {
// In Jest, 'EventTarget' is from Node.js
const target = new EventTarget();
// In JSDOM, 'Event' is a polyfill from JSDOM
const customEvent = new Event('my-event');
try {
// The dispatch tries to use two incompatible implementations
target.dispatchEvent(customEvent);
setMessage('Event received!');
} catch (e: any) {
setMessage(`Error: ${e.message}`);
}
}, []);
return <div>{message}</div>;
};
The JSDOM Test: EventCompatibilityCheck.test.tsx (Fails Correct Code ❌)
it('fails with a TypeError due to incompatible APIs', () => {
render(<EventCompatibilityCheck />);
// This test proves that the component code, although correct,
// failed to run inside the JSDOM environment.
expect(screen.getByText(/Error: The "event" argument must be an instance of Event/)).toBeInTheDocument();
});
Why This Is a Problem: The test fails with a TypeError because the Event object created by JSDOM is not a compatible instance of the Event that the native Node.js EventTarget expects. Your code is right, but the environment is a broken hybrid.
The Vitest Browser Test: EventCompatibilityCheck.spec.tsx (Passes Correct Code ✅)
it('dispatches native browser events without error', () => {
render(<EventCompatibilityCheck />);
// In a real browser, Event and EventTarget are from the same universe.
// The code works as intended.
expect(screen.getByText('Event received!')).toBeInTheDocument();
});
3. The Silent Failure: Incorrect Dependency Resolution
This is the most insidious issue, because the JSDOM test passes, giving you a false sense of security. Your component uses a library like msw that has different exports for browser and Node.
The Component: DependencyCheck/index.tsx
import { setupWorker } from 'msw/browser';
export const DependencyCheck = () => {
// If setupWorker is imported, the 'browser' module was resolved.
if (typeof setupWorker === 'function') {
return <div>"browser" export resolved correctly!</div>;
}
return <div>Something went wrong.</div>;
};
The JSDOM Test: DependencyCheck.test.tsx (A Misleading Pass ❌)
it('resolves the browser module', () => {
render(<DependencyCheck />);
// This test PASSES...
expect(screen.getByText(/"browser" export resolved/)).toBeInTheDocument();
});
Why This Is a Problem: The test passes, but it has loaded browser-specific code into a Node.js runtime. This is a configuration that will never exist in production. If that browser code tried to access a browser-only API (like Service Workers), it would crash. The test is green, but it has tested nothing of value.
The Final Proof: See It For Yourself
Theory is one thing, but seeing is believing. To prove every point in this article, I built a Proof of Concept (PoC) repository with all the components and tests I've shown. You can clone it, run npm install, and execute the tests for JSDOM, Cypress, and Vitest.
You will see firsthand how the JSDOM tests fail on correct code, while the real-browser tests pass reliably.
Check out the repository here: https://github.com/Felipebasilio/poc-problematic-jsdom
Conclusion
Based on these findings, it's clear that moving to real-browser testing is a logical next step for ensuring the reliability of our component tests. While JSDOM has been an invaluable tool for years, its focus has clearly been on adding new features rather than addressing some of the core architectural inconsistencies that cause these testing conflicts. This isn't a failure of the library, but rather a signal that our needs as developers have evolved.
When considering alternatives, Cypress Component Testing stands out today as a mature, robust, and production-ready solution. It offers a stable environment and a fantastic developer experience for teams that need to implement reliable browser-based testing immediately.
At the same time, Vitest Browser Mode is an extremely promising alternative, especially for teams already in the Vite ecosystem. While it is still marked as experimental, its rapid development and seamless integration make it worth exploring in parallel. It's a tool to test, monitor, and prepare for, as it may well become a leading choice once it solidifies.
Ultimately, the goal is to increase confidence in our tests. The evidence shows that the most effective way to do this is to test our code in the only environment that truly matters: a real browser.