Skip to content

parse() shows near-cubic time blowup when TextNode and Element siblings alternate under one parent #317

Description

@nurkiewicz

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions