jsdom
jsdom is a pure-JavaScript implementation of the WHATWG DOM and HTML standards that runs inside Node.js. It parses HTML the way a browser does, builds a real document tree with querySelector, events, localStorage, cookies, and most of the DOM API, and can even execute scripts inside the page if you let it. It is the engine behind the jsdom test environment in Jest and Vitest, which is where most people meet it without knowing. It deliberately does not render anything: there is no layout, no navigation, and getBoundingClientRect returns zeros. Think of it as a headless document, not a headless browser.
The default DOM for Node test environments and the most spec-faithful way to handle HTML outside a browser, at a real cost in weight and speed. Use it when correctness matters, drop to cheerio for plain scraping, and go to Playwright when layout or navigation enters the picture.
Use it if
- You run component tests in Jest or Vitest and need document, window, and events without booting a real browser for every test
- You scrape or transform server-rendered HTML and need spec-correct parsing plus the full DOM API, not just selectors
- You need to run third-party scripts against a fake page in Node, for example to test an analytics snippet or an embed widget
- You build tooling that must serialize a document back to HTML exactly the way a browser would, doctype and implied tags included
- You test anything that depends on layout, sizes, or real rendering: offsetTop and getBoundingClientRect return zeros, so use Playwright or real-browser test runners instead
- You only need to extract data from HTML: cheerio or linkedom parse and query at a fraction of the memory and startup cost of a full jsdom window
- You want to crawl JavaScript-heavy sites: jsdom has no navigation, many pages break on its partial API surface, and running untrusted scripts with runScripts: 'dangerously' can reach your Node process
- Your test suite is large and slow: happy-dom implements less of the spec but is noticeably faster as a test environment, which is why Vitest offers it as a first-class option
- You are pinned to an older Node: jsdom 30 requires Node 22.22, 24.15, or 26+, so legacy runtimes force you onto old majors
Setup reality
npm install jsdom pulls in roughly two dozen dependencies (parse5, undici, tough-cookie, css-tree and friends), so it is a heavy install for what looks like one package. The engines field is strict: v30 wants Node 22.22+, 24.15+, or 26+, and CI images on older Node fail at install or runtime. canvas is an optional peer dependency; without it every <canvas> behaves like a div, and installing it means native builds with Cairo system packages. In test runners you rarely configure jsdom directly, but upgrading the runner can jump you across jsdom majors and change parsing or resource behavior. Also plan for cleanup: outstanding window timers keep the process alive until you call window.close().
Patterns
Parse HTML and query the documentparse-html
const { JSDOM } = require("jsdom");
const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`);
console.log(dom.window.document.querySelector("p").textContent); // "Hello world"jsdom parses like a browser, so implied html, head, and body tags are created even if your string omits them.
Execute scripts embedded in the pagerun-page-scripts
const dom = new JSDOM(`<body>
<div id="content"></div>
<script>document.getElementById("content").append(document.createElement("hr"));</script>
</body>`, { runScripts: "dangerously" });
console.log(dom.window.document.getElementById("content").children.length); // 1Only use "dangerously" with HTML you trust; in-page code can escape the sandbox and reach your Node process.
Run your own code against the pageeval-from-outside
const dom = new JSDOM(`<div id="content"></div>`, { runScripts: "outside-only" });
dom.window.eval('document.getElementById("content").append(document.createElement("p"));');
console.log(dom.window.document.querySelector("p") !== null); // true"outside-only" installs fresh JS globals on window and enables window.eval without letting page scripts run; it is safe to enable.
Build a jsdom from a live URLload-from-url
const dom = await JSDOM.fromURL("https://example.com/", {
referrer: "https://google.com/",
});
console.log(dom.serialize().slice(0, 60));Redirects are followed and Set-Cookie headers land in the cookie jar; you cannot pass url or contentType options here.
Build a jsdom from an HTML fileload-from-file
const dom = await JSDOM.fromFile("stuff.html");
console.log(dom.window.document.title);The url option defaults to the matching file:// URL, so relative links resolve against the file's directory.
Parse a fragment without a full windowparse-fragment
const frag = JSDOM.fragment(`<p>Hello</p><p><strong>Hi!</strong>`);
frag.childNodes.length === 2;
frag.querySelector("strong").textContent === "Hi!";All fragments share one template document, so this is cheap, but resources never load and options cannot be passed.
Capture or silence page console outputsilence-console
const jsdom = require("jsdom");
const virtualConsole = new jsdom.VirtualConsole();
virtualConsole.on("error", (msg) => myLogger.error(msg));
virtualConsole.forwardTo(console, { jsdomErrors: "none" });
const dom = new JSDOM(``, { virtualConsole });Attach listeners before constructing the JSDOM; parse-time CSS errors fire immediately and are easy to miss.
Control location, referrer, and originset-page-url
const dom = new JSDOM(``, {
url: "https://example.org/",
referrer: "https://example.com/",
contentType: "text/html",
});
console.log(dom.window.location.href); // "https://example.org/"url defaults to about:blank, which makes every relative URL in the page unresolvable, so set it whenever resources or links matter.
Load external scripts and stylesheetsload-subresources
const dom = new JSDOM(html, {
url: "https://example.com/",
resources: "usable",
runScripts: "dangerously",
});resources: "usable" loads frames, stylesheets, and scripts (scripts only together with runScripts); images additionally need the canvas package installed.
Mock a network response for the pageintercept-requests
const { JSDOM, requestInterceptor } = require("jsdom");
const dom = new JSDOM(`<script src="https://example.com/app.js"></script>`, {
url: "https://example.com/",
runScripts: "dangerously",
resources: {
interceptors: [
requestInterceptor((request) => {
if (request.url === "https://example.com/app.js") {
return new Response("window.someGlobal = 5;", {
headers: { "Content-Type": "application/javascript" },
});
}
}),
],
},
});This undici-interceptor style replaced the old ResourceLoader subclassing; returning undefined lets the request continue normally.
Enable requestAnimationFrame in testsfake-visibility
const { window } = new JSDOM(``, { pretendToBeVisual: true });
window.requestAnimationFrame((timestamp) => {
console.log(timestamp > 0); // true
});Without pretendToBeVisual, requestAnimationFrame does not exist and document.hidden is true, which trips libraries that pause when hidden.
Shut a jsdom down cleanlycleanup-window
const dom = new JSDOM(html, { runScripts: "dangerously" });
// ... use the dom ...
dom.window.close(); // kills timers and listenersPending setTimeout and setInterval calls inside the page keep the Node process alive and block garbage collection until close() runs.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| happy-dom | npm | You want a faster, lighter DOM for component tests and can live with less spec coverage |
| cheerio | npm | You only parse and query HTML for scraping and never need window, events, or scripts |
| linkedom | npm | You need DOM-shaped parsing and serialization in SSR or workers with minimal memory overhead |
| playwright | npm | You need real rendering, layout, navigation, or must trust the results against actual browsers |