parse() scales roughly linearly with sibling count when a parent's childNodes are homogeneous (all elements, or a single text node), but shows near-cubic growth when text nodes and element/void-tag nodes alternate directly under the same parent, at scale.
Environment
- node-html-parser: 9.0.1
- Node.js: v24.14.1
Repro
const { parse } = require('node-html-parser');
function time(fn) {
const start = process.hrtime.bigint();
fn();
return Number(process.hrtime.bigint() - start) / 1e6; // ms
}
for (const n of [1000, 2000, 4000, 8000, 16000, 32000]) {
const html = `<div>${'line<br>'.repeat(n)}</div>`;
console.log(n, time(() => parse(html)).toFixed(1), 'ms');
}
This is a plain text run followed by a <br>, repeated n times, all under one <div>. It's a common real-world shape: plaintext email replies quoted as HTML by legacy mail clients (bare text<br> runs, no wrapping elements).
Results
| n (siblings) |
time (ms) |
| 1000 |
9.0 |
| 2000 |
26.0 |
| 4000 |
88.6 |
| 8000 |
137.0 |
| 16000 |
768.3 |
| 32000 |
3743.9 |
32× the input produces ~415× the time.
For comparison, homogeneous sibling arrays of the same sizes stay linear:
| n |
all <br>, no text (ms) |
one text node, no tags (ms) |
| 1000 |
1.7 |
0.1 |
| 32000 |
15.4 |
0.1 |
So it isn't sibling count alone — it's specifically text/element alternation under one shared parent array. I also tried alternating two element types (<span>x</span><br>, text kept inside each span rather than the div) and that stayed linear too. Only TextNode+HTMLElement (or CommentNode) mixed directly in one parent's array reproduces it.
I profiled with node --prof and the cost sits inside base_parse itself plus disproportionate GC, not in any single named helper (appendChild, before/after, remove() etc. are not on the hot path during initial parsing). My guess is that pushing very different object shapes (TextNode vs HTMLElement, which also constructs a DOMTokenList per instance) into the same growing array degrades a V8 fast-array/inline-cache path.
I checked #260 first since it's also a parse() CPU issue, but its workaround (parseNoneClosedTags: true) makes no difference here (well-formed HTML, no unclosed tags), so this looks like a separate issue.
Happy to provide more detail or test a fix.
parse()scales roughly linearly with sibling count when a parent'schildNodesare homogeneous (all elements, or a single text node), but shows near-cubic growth when text nodes and element/void-tag nodes alternate directly under the same parent, at scale.Environment
Repro
This is a plain text run followed by a
<br>, repeatedntimes, all under one<div>. It's a common real-world shape: plaintext email replies quoted as HTML by legacy mail clients (baretext<br>runs, no wrapping elements).Results
32× the input produces ~415× the time.
For comparison, homogeneous sibling arrays of the same sizes stay linear:
<br>, no text (ms)So it isn't sibling count alone — it's specifically text/element alternation under one shared parent array. I also tried alternating two element types (
<span>x</span><br>, text kept inside each span rather than the div) and that stayed linear too. Only TextNode+HTMLElement (or CommentNode) mixed directly in one parent's array reproduces it.I profiled with
node --profand the cost sits insidebase_parseitself plus disproportionate GC, not in any single named helper (appendChild,before/after,remove()etc. are not on the hot path during initial parsing). My guess is that pushing very different object shapes (TextNodevsHTMLElement, which also constructs aDOMTokenListper instance) into the same growing array degrades a V8 fast-array/inline-cache path.I checked #260 first since it's also a
parse()CPU issue, but its workaround (parseNoneClosedTags: true) makes no difference here (well-formed HTML, no unclosed tags), so this looks like a separate issue.Happy to provide more detail or test a fix.