Skip to content

Memory leak and increasing latency in long-running processes #368

@nwalters512

Description

@nwalters512

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions