isomorphic-dompurify icon indicating copy to clipboard operation
isomorphic-dompurify copied to clipboard

Memory leak and increasing latency in long-running processes

Open nwalters512 opened this issue 3 months ago • 6 comments

Summary

When DOMPurify is used through isomorphic-dompurify in a long‑running Node.js process, repeated calls to DOMPurify.sanitize(...) become progressively slower and heap usage grows without bound. The root cause appears to be that isomorphic-dompurify creates a single jsdom window, and DOMPurify keeps appending to that shared DOM state; nothing ever gets reclaimed. After a few thousand sanitizations, each sanitize call is an order of magnitude slower and heap usage climbs into the hundreds of MBs.

Environment

  • Node.js v24.6.0 (also reproduced on v20)
  • dompurify 3.3.0
  • isomorphic-dompurify 2.31.0
  • jsdom 27.1.0

Minimal reproduction

import { performance } from 'node:perf_hooks';

import DOMPurify from 'isomorphic-dompurify';

const html = '<p>Hello</p>';

for (let i = 1; ; i += 1) {
  const start = performance.now();
  DOMPurify.sanitize(html);
  if (i % 1000 === 0) {
    const durationMs = performance.now() - start;
    const heapMb = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(`${i} calls -> ${durationMs.toFixed(3)} ms, heap ${heapMb.toFixed(1)} MB`);
  }
}

Running this script produces steadily increasing latencies and heap usage:

1000 calls -> 0.320 ms, heap 87.5 MB
2000 calls -> 0.532 ms, heap 144.5 MB
3000 calls -> 0.911 ms, heap 187.0 MB
4000 calls -> 1.238 ms, heap 243.7 MB
5000 calls -> 1.754 ms, heap 278.5 MB
6000 calls -> 2.399 ms, heap 330.3 MB
7000 calls -> 3.373 ms, heap 383.3 MB
8000 calls -> 3.970 ms, heap 451.9 MB
9000 calls -> 4.847 ms, heap 476.3 MB

The growth continues until the process is manually killed.

Workaround

If I create a new jsdom window/DOMPurify instance for each sanitize and close it afterwards, the leak disappears and the per-call time stays flat:

import { performance } from 'node:perf_hooks';

import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

const html = '<p>Hello</p>';

for (let i = 1; i <= 1000; i += 1) {
  const start = performance.now();
  const { window } = new JSDOM('<!DOCTYPE html>');
  const DOMPurify = createDOMPurify(window);
  DOMPurify.sanitize(html);
  window.close();
  if (i % 100 === 0) {
    console.log(`${i} calls -> ${(performance.now() - start).toFixed(3)} ms`);
  }
}
100 calls -> 2.425 ms
200 calls -> 2.157 ms
300 calls -> 1.654 ms
400 calls -> 1.715 ms
500 calls -> 1.620 ms
600 calls -> 2.438 ms
700 calls -> 1.517 ms
800 calls -> 9.568 ms
900 calls -> 1.477 ms
1000 calls -> 1.476 ms

Request

Could the isomorphic-dompurify maintainers confirm the leak and either (1) clear the jsdom document between sanitizations, (2) stop reusing a shared window in Node, or (3) document that the module isn’t safe for long-lived server processes? I'd be more than happy to help test potential fixes.

Disclosure: GPT-5-Codex helped author this issue text and the benchmarks. I tweaked both the text and the scripts for clarity.

nwalters512 avatar Nov 07 '25 00:11 nwalters512

As for why I filed this here and not in dompurify itself, dompurify doesn't seem to exhibit the same behavior when it's running in the browser. In a browser JS console with a DOMPurify global available, run the following:

const html = '<p>Hello</p>';

for (let i = 1; ; i += 1) {
  const start = performance.now();
  DOMPurify.sanitize(html);
  if (i % 10000 === 0) {
    console.log(`${i} calls -> ${(performance.now() - start).toFixed(3)} ms`);
  }
}

Over tens of millions of iterations, the average call duration is consistently <0.1ms and memory usage remains essentially flat.

nwalters512 avatar Nov 07 '25 16:11 nwalters512

@nwalters512 Thank you for reporting. I need some time to reproduce the issue and confirm back.

As for using dompurify in the browser, I guess you'd have the same result with isomorphic-dompurify in the browser because a virtual JSDOM tree is not created/used in the browser.

kkomelin avatar Nov 07 '25 19:11 kkomelin

Hey @nwalters512 ,

I used your first script to calculate time and heap for a regular isomorphic-dompurify calls:

1000 calls -> 0.604 ms, heap 92.0 MB
2000 calls -> 1.122 ms, heap 130.9 MB
3000 calls -> 1.731 ms, heap 180.3 MB
4000 calls -> 3.216 ms, heap 227.6 MB
5000 calls -> 4.800 ms, heap 279.9 MB
6000 calls -> 9.255 ms, heap 326.0 MB
7000 calls -> 10.902 ms, heap 376.9 MB

and for your dompurify+jsdom workaround:

1000 calls -> 2.729 ms, heap 717.2 MB
2000 calls -> 3.652 ms, heap 1284.5 MB
3000 calls -> 3.105 ms, heap 2022.8 MB
4000 calls -> 2.379 ms, heap 2477.3 MB
5000 calls -> 2.643 ms, heap 3041.6 MB
6000 calls -> 3.009 ms, heap 3678.9 MB
... FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

The script I used:

import { performance } from "node:perf_hooks";

import { JSDOM } from "jsdom";
import createDOMPurify from "dompurify";
// import DOMPurify from "isomorphic-dompurify";

const html = "<p>Hello</p>";

for (let i = 1; ; i += 1) {
  const start = performance.now();

  // isomorphic-dompurify without explicit resources release.
  // DOMPurify.sanitize(html);

  // dompurify with explicit resources release.
  const { window } = new JSDOM("<!DOCTYPE html>");
  const DOMPurify = createDOMPurify(window);
  DOMPurify.sanitize(html);
  window.close();

  if (i % 1000 === 0) {
    const durationMs = performance.now() - start;
    const heapMb = process.memoryUsage().heapUsed / 1024 / 1024;
    console.log(
      `${i} calls -> ${durationMs.toFixed(3)} ms, heap ${heapMb.toFixed(1)} MB`
    );
  }
}

My opinion is that your workaround doesn't solve the problem, and it's unlikely that the issue is in isomorphic-dompurify, so I think we need to report it to dompurify and ask them for opinion/advice.

kkomelin avatar Nov 08 '25 12:11 kkomelin

Thanks for looking into it! I should have shared an update of my own, I found what I think is an important factor here: it seems to be necessary to yield to the event loop periodically to allow jsdom to free things for garbage collection. If you add a sleep(0) after every N iterations, there's no unbounded memory growth. You can see that in the benchmarking script in https://github.com/PrairieLearn/PrairieLearn/pull/13310.

Note that it is indeed still necessary to periodically close and replace the jsdom instance.

So I think there's two things at play here:

  • Regardless of how often one yields to the event loop, the fact that isomorphic-dompurify uses a single jsdom instance forever means that memory is still leaked and one will eventually OOM. I think this could be seen as an issue with either jsdom or isomorphic-dompurify.
  • My original workaround was indeed faulty, and breaking up long stretches of synchronous work was necessary to avoid OOMing. I apologize for this; I must have gotten some wires crossed after a long afternoon of debugging.

Given that I do have an actual workaround now, I'm fine if you want to close this issue; just note that the workaround did involve removing isomorphic-dompurify and working directly with dompurify and jsdom.

nwalters512 avatar Nov 08 '25 15:11 nwalters512

@nwalters512 No, no, I greatly appreciate your findings. It's important. Let's keep it open.

It's absolutely fair that you chose dompurify+jsdom fix. Business goals have top priority.

I'd like to find a simple solution. One of my ideas was to actually override the sanitize() function in isomorphic-dompurify and close the jsdom's window inside it after calling the parent dompurify's sanitize(). Or now your new sleep(0) solution could do the trick there. Will think more about it.

kkomelin avatar Nov 08 '25 15:11 kkomelin

Tested regular isomorphic-dompurify calls with await new Promise((resolve) => setImmediate(resolve)); in every loop, the heap usage improved greatly but the latency was slowly growing:

1000 calls -> 2.212 ms, heap 52.1 MB
2000 calls -> 4.895 ms, heap 98.8 MB
3000 calls -> 1.918 ms, heap 65.9 MB
4000 calls -> 5.044 ms, heap 117.5 MB
5000 calls -> 9.178 ms, heap 165.3 MB
6000 calls -> 11.940 ms, heap 85.7 MB
7000 calls -> 16.673 ms, heap 83.4 MB
8000 calls -> 20.034 ms, heap 81.7 MB

so yeah, looks like we need to close the window once in a while, for example, like you do it on every 1000s call.

kkomelin avatar Nov 08 '25 16:11 kkomelin