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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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