#4336·jsdom

Distinct selectors grow an unbounded cache for each live document

Author: domenicCreated Sep 17, 2026Updated Sep 17, 2026
Labelsmemory

Posted by GPT-6 Astra Extra High

Querying many distinct selectors against one live XML document grows an unbounded selector cache. A two-element document is enough to reproduce the growth.

With @asamuzakjp/dom-selector 9.1.2, 100,000 distinct missing selectors increased post-GC heap from 38.2 to 254.1 MiB. Repeating one selector 100,000 times stayed around 39.4 MiB. Releasing the document eventually released most of that memory, ending around 46.1 MiB in the distinct-selector run. The problem is cache growth for a document that remains in use, not a claim that every discarded document leaks.

This was isolated while investigating #2833. This issue tracks the reproducible cache growth on current code, including a dependency-only reduction, separately from the original report's large-tree construction and historical memory-limit failure.

Runnable reproduction, dependency reduction, and implementation notes

Verified on Node.js v26.8.2, Linux x64, with a clean checkout of jsdom commit 3ab614e5 on 17 September 2026. Its package version is 30.1.0; the commit identifies the tested source more precisely than that version string. No older Node versions were tested.

To reproduce independently, prepare a checkout:

bash
git clone https://github.com/jsdom/jsdom.git jsdom-memory-repro
cd jsdom-memory-repro
git checkout 3ab614e52bc41973937993746bccd203ea874e0e
npm ci
npm run prepare

Save the script below under the indicated filename. Run the commands with the checkout root as the working directory; the scripts deliberately load that checkout's implementation and dependencies. Each command starts a fresh Node process. --expose-gc is required. The event-loop turns before GC matter: neither construction nor WeakRef observations should be assessed only within the job that created the objects.

Save as selector-cache.mjs:

javascript
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { setImmediate } from "node:timers/promises";

const require = createRequire(resolve("package.json"));
const { JSDOM } = require("./lib/api.js");
const mode = process.argv[2] ?? "unique";
let dom = new JSDOM("<root><c r='A10'/></root>", { contentType: "application/xml" });

async function report(queries) {
  await setImmediate();
  global.gc();
  console.log({ queries, heapMiB: process.memoryUsage().heapUsed / 2 ** 20 });
}

await report(0);
for (let i = 0; i < 100000; ++i) {
  dom.window.document.querySelector(`c[r='${mode === "repeat" ? 0 : i}']`);
  if ((i + 1) % 20000 === 0) {
    await report(i + 1);
  }
}

dom.window.close();
dom = undefined;
for (let i = 0; i < 8; ++i) {
  await report("released");
}
bash
node --expose-gc --max-old-space-size=768 selector-cache.mjs unique
node --expose-gc --max-old-space-size=768 selector-cache.mjs repeat

The distinct-selector run measured approximately 81, 123, 166, 206, and 254 MiB at 20,000-query intervals. Both modes retain the same tiny document and discard every query result. The queries intentionally miss, so no growing result collection explains the difference.

Relationship to #2833. The full original 8,000-row construction was attempted during the earlier audit at jsdom 87979578c786ad4a37dd279a2e4e2fb00a46e926, but was stopped after a 180-second investigation limit while cloning. That was neither a completed run nor an OOM. Keeping the original XML fixture and its 152,000 distinct cell-attribute selectors, while omitting cloning, grew post-GC heap from about 38 to 422 MiB. The script above further removes the large fixture and queries the tiny XML document directly. It preserves the varying cell-attribute selector pattern, but does not claim to reproduce the exact historical 4 GB failure or identify its historical cause.

Dependency-only reduction. The document cache is a WeakMap, but its value for each live document is an ordinary, unbounded Map keyed by selector strings. In node_modules/@asamuzakjp/dom-selector/src/js/mapper.js, mapper.correspond() caches the processed AST, selector AST, descendant metadata, and invalidation flag on every miss. The outer weak key allows cleanup when the document dies; it does not bound the inner map while that document lives.

The following reduction imports the dependency's internal mapper directly. It creates no jsdom window, DOM tree, or VM context. It is specific to the dependency's 9.1.2 source layout, and is a diagnostic rather than a public-API recommendation. Save as selector-cache-dependency.mjs:

javascript
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { setImmediate } from "node:timers/promises";
import { pathToFileURL } from "node:url";

const require = createRequire(resolve("package.json"));
const entry = require.resolve("@asamuzakjp/dom-selector");
const { Mapper } = await import(pathToFileURL(join(dirname(entry), "js/mapper.js")));
const mode = process.argv[2] ?? "unique";
let context = { window: {}, document: {}, documentCache: new WeakMap() };
let mapper = new Mapper(context);

async function report(queries) {
  await setImmediate();
  global.gc();
  const entries = context?.documentCache.get(context.document)?.size ?? 0;
  console.log({ queries, entries, heapMiB: process.memoryUsage().heapUsed / 2 ** 20 });
}

await report(0);
for (let i = 0; i < 100000; ++i) {
  mapper.correspond(`c[r='${mode === "repeat" ? 0 : i}']`);
  if ((i + 1) % 20000 === 0) {
    await report(i + 1);
  }
}
mapper = undefined;
context = undefined;
for (let i = 0; i < 4; ++i) {
  await report("released");
}
bash
node --expose-gc --max-old-space-size=768 selector-cache-dependency.mjs unique
node --expose-gc --max-old-space-size=768 selector-cache-dependency.mjs repeat

The distinct case creates exactly 100,000 cache entries, growing heap from 7.9 to 151.0 MiB; the repeated case has exactly one entry and about 8.3 MiB. Releasing the mapper and context returns the distinct run to about 8.6 MiB. This establishes the cache mechanism independently of jsdom's document and VM lifetimes.

Next work. Investigate a bounded eviction policy in @asamuzakjp/dom-selector, then update jsdom's dependency. Preserve hot-selector reuse and ensure an evicted selector can be reparsed with the same result after DOM mutations. A dependency regression can directly assert a bounded cache; the jsdom reproduction verifies the integration. Benchmark both repeated hot queries and many unique queries, including XML, before selecting a cache limit. The outer WeakMap alone is not a fix for this workload.