mrkeyoor.com_
Thu 06 Aug 00:57 UTC
npmWeb Frontendupdated 05 Aug 2026

htmlparser2

htmlparser2 is a fast, forgiving HTML and XML parser for JavaScript. Its core is a SAX-style callback interface: you hand it a handler object with onopentag, ontext, and onclosetag functions, feed it markup in chunks, and it fires events with minimal allocations. On top of that it ships parseDocument for building a DOM tree (via domhandler), DomUtils for querying and editing that tree, streaming support through a WritableStream class, and a parseFeed helper for RSS, Atom, and RDF. It trades strict spec compliance for speed, and it is the parsing layer under cheerio, so most people already run it indirectly.

Verdict

The right engine when you are building parsing infrastructure or streaming through markup at volume, which is why cheerio and about 90M weekly downloads sit on top of it. For everyday scraping use cheerio, and for browser-identical trees use parse5.

API stability4/5The event and options API has been recognizably the same for a decade; majors mostly bump Node requirements and dependency majors (v12 needs Node 20.19+) rather than reshaping the API, though majors do arrive frequently.
Docs3/5The README documents events, options, and streaming well, but there is no full docs site, and real usage quickly depends on domhandler, domutils, and css-select docs spread across separate repos.
Maintenance4/5Maintained by fb55, who also maintains the whole cheerio parsing stack, with a push in August 2026 and only 11 open issues and PRs; the concentration on one maintainer is the main caveat.
Ecosystem5/5About 90M weekly downloads and it is the foundation of the cheerio family (domhandler, domutils, css-select, dom-serializer), so it is load-bearing for a huge share of Node HTML processing.

Use it if

  • You process large or streaming HTML (crawlers, proxies, feed fetchers) and want events per tag instead of holding a whole DOM in memory
  • You need one parser for both sloppy real-world HTML and XML feeds, with an xmlMode switch and a built-in parseFeed for RSS and Atom
  • You are building your own tooling (sanitizer, link extractor, markup rewriter) and want low-level control over every tag and attribute event
  • You need raw parsing speed and can accept that malformed markup is repaired differently than a browser would repair it
Skip it if

Setup reality

npm install htmlparser2 brings four small pure-JS dependencies (domhandler, domutils, domelementtype, entities), no native builds, and dual ESM/CommonJS support. Version 12 requires Node 20.19+, so older runtimes are stuck on v10 or v9. The conceptual setup is the real cost: the callback API fires ontext multiple times for one text node, so you must buffer and stitch pieces yourself, and closing tags without openers are silently dropped. For DOM work you learn three packages (domhandler shapes, DomUtils helpers, css-select for selectors) because the main package alone only gets you the tree.

Patterns

Parse with tag and text callbackssax-callbacks

import * as htmlparser2 from "htmlparser2";

const parser = new htmlparser2.Parser({
  onopentag(name, attributes) {
    if (name === "a") console.log("link:", attributes.href);
  },
  ontext(text) {
    console.log("text:", text);
  },
  onclosetag(tagname) {
    if (tagname === "a") console.log("link done");
  },
});
parser.write('<a href="/docs">Docs</a>');
parser.end();

ontext can fire several times inside one text node; concatenate chunks until the next tag event if you need whole strings.

Parse a string into a DOM treeparse-document

import * as htmlparser2 from "htmlparser2";

const dom = htmlparser2.parseDocument(
  `<ul id="fruits">
     <li class="apple">Apple</li>
     <li class="orange">Orange</li>
   </ul>`,
);
console.log(dom.children.length);

The result is a domhandler Document, a plain node graph with children/parent links; it has no querySelector or DOM methods.

Find nodes with DomUtilsquery-dom

import * as htmlparser2 from "htmlparser2";

const dom = htmlparser2.parseDocument('<div><p id="greeting">Hello</p></div>');

const greeting = htmlparser2.DomUtils.getElementById("greeting", dom);
const paragraphs = htmlparser2.DomUtils.getElementsByTagName("p", dom);
console.log(htmlparser2.DomUtils.textContent(greeting)); // "Hello"

DomUtils is re-exported from the main package; findAll with a test function covers anything the named helpers do not.

Query the tree with CSS selectorscss-selectors

import * as htmlparser2 from "htmlparser2";
import { selectAll, selectOne } from "css-select";

const dom = htmlparser2.parseDocument(html);
const items = selectAll("ul#fruits > li", dom);
const first = selectOne("li.apple", dom);

css-select is a separate install; it understands domhandler trees natively, which is exactly what cheerio does internally.

Parse a file or response as a streamstream-parse

import fs from "node:fs";
import { WritableStream } from "htmlparser2/WritableStream";

const parserStream = new WritableStream({
  ontext(text) {
    console.log("chunk:", text);
  },
});

fs.createReadStream("./my-file.html")
  .pipe(parserStream)
  .on("finish", () => console.log("done"));

Import from the htmlparser2/WritableStream subpath; the main Parser looks stream-like but is not a real Node stream.

Parse an RSS or Atom feedparse-rss-feed

import * as htmlparser2 from "htmlparser2";

const feed = htmlparser2.parseFeed(xmlContent);
if (feed) {
  console.log(feed.title);
  for (const item of feed.items) {
    console.log(item.title, item.link, item.pubDate);
  }
}

parseFeed returns null for unrecognized formats; it enables xmlMode by default, so include xmlMode: true if you pass custom options.

Parse XML instead of HTMLxml-mode

import * as htmlparser2 from "htmlparser2";

const dom = htmlparser2.parseDocument(xml, {
  xmlMode: true,
});

xmlMode preserves tag case, honors self-closing tags, and treats CDATA properly; without it XML namespaces and casing get mangled.

Edit the tree and serialize back to HTMLmodify-and-serialize

import * as htmlparser2 from "htmlparser2";

const dom = htmlparser2.parseDocument("<ul><li>Apple</li><li>Orange</li></ul>");

const items = htmlparser2.DomUtils.getElementsByTagName("li", dom);
htmlparser2.DomUtils.removeElement(items[0]);

const html = htmlparser2.DomUtils.getOuterHTML(dom);
// "<ul><li>Orange</li></ul>"

getOuterHTML is dom-serializer under the hood; entities are re-encoded on output, so byte-identical round-trips are not guaranteed.

Track source positions of nodesnode-positions

import * as htmlparser2 from "htmlparser2";

const dom = htmlparser2.parseDocument(html, {
  withStartIndices: true,
  withEndIndices: true,
});
const p = htmlparser2.DomUtils.getElementsByTagName("p", dom)[0];
console.log(p.startIndex, p.endIndex);

These are domhandler options passed through parseDocument's second argument; indices are byte offsets into the original input string.

Inspect attributes and quoting as they parseattribute-details

import * as htmlparser2 from "htmlparser2";

const parser = new htmlparser2.Parser({
  onattribute(name, value, quote) {
    // quote is '"', "'", null (unquoted), or undefined (bare attr)
    console.log(name, value, quote);
  },
});
parser.write("<input disabled value=x>");
parser.end();

onattribute fires before onopentag delivers the aggregated object; bare boolean attributes arrive with quote === undefined.

Feed markup in arbitrary chunkschunked-input

import * as htmlparser2 from "htmlparser2";

const parser = new htmlparser2.Parser({
  onopentag(name) {
    console.log("open", name);
  },
});
parser.write("<di");
parser.write("v><p>split across");
parser.write(" writes</p></div>");
parser.end();

The tokenizer buffers across write() boundaries, so tags split mid-name are fine; nothing final is emitted until end() flushes.

Reset and reuse one parser instancereuse-parser

import * as htmlparser2 from "htmlparser2";

const parser = new htmlparser2.Parser(handler);
for (const doc of documents) {
  parser.write(doc);
  parser.end();
  parser.reset(); // ready for the next document
}

reset() fires onreset and reinitializes state, which avoids re-allocating parsers in hot loops; call it after end(), not instead of it.

Alternatives

PackageRegistryPick it when
parse5npmYou need WHATWG-spec-compliant parsing that matches browser error recovery exactly, and can accept it being several times slower
cheerionpmYou are scraping and want jQuery-style selectors and manipulation; it uses htmlparser2 or parse5 underneath
node-html-parsernpmYou want a small single-package DOM with querySelector built in and comparable speed
jsdomnpmYou need a real DOM with events, scripts, and browser semantics, not just a parse tree