On one machine, cheerio in Node parsed and extracted titles plus hrefs from a 10 MB synthetic HTML document in 2,927.89 ms. selectolax, running through CPython with a C-backed parser, completed the same field extraction in 158 ms. The sorted title text and href hashes matched. This is an end-to-end stack comparison across runtimes, not an isolated parser-algorithm verdict.
On a 10 KB page the gap is 2Ă—, and nobody would notice. The whole question is where your pages sit on that curve.
What cheerio is
cheerio is the jQuery-syntax HTML parser for Node, and it is the default answer in that ecosystem for good reason: 30,449 GitHub stars, MIT licensed, and a push to the repository the day before I ran this. Version tested: 1.2.0.
Official reference: Cheerio's official introduction.
import * as cheerio from "cheerio";
const $ = cheerio.load(html);
const titles = $("h3.title").map((_, e) => $(e).text()).get();
const hrefs = $("a").map((_, e) => $(e).attr("href")).get();
If you have written jQuery, you already know the API. That familiarity is most of why it won its ecosystem.
Underneath, it is not one parser but a stack: htmlparser2 and parse5 for parsing, domhandler and domutils for the tree, cheerio-select for selectors, plus undici, encoding-sniffer and others — eleven direct dependencies, resolving to 22 top-level packages and 9.0 MiB on disk. Those packages provide parsing and encoding capabilities, and they also contribute to the dependency footprint. This review tested malformed-input output but did not test encoding correctness or isolate either parser backend.
The measurement, and why it is trustworthy
This research base already had a parser bench: five page sizes from 1 KB to 10 MB, 50 iterations, three independent runs, and — the part that matters — a parity gate that hashes the extracted content, sorted titles plus sorted hrefs, against a reference parser. A parser that quietly skips work cannot post a fast time.
Adding cheerio to it needed two checks before any number counted.
Did the reference land where it did before? selectolax was re-run in the same session on the same fixtures. Its content hash reproduced on 5 of 5 sizes, and its p50 came in between 0.989Ă— and 1.079Ă— of the published figure. So this is the machine that produced the original table.
Did cheerio produce the same scored fields? Its content hash — computed in Node with the identical rule, SHA-256 over sorted title text and sorted hrefs — matched the reference on 5 of 5 sizes. That proves parity for those sorted fields on these fixtures, not DOM shape, document order, attributes, text normalization, or error recovery.
Only then do the timings mean anything.
| Page size | selectolax | lxml | PyQuery | cheerio (Node) | cheerio vs selectolax |
|---|---|---|---|---|---|
| 1 KB | 0.0286 ms | 0.0508 | 0.0456 | 0.1147 ms | 4.0Ă— |
| 10 KB | 0.1725 ms | 0.1802 | 0.1728 | 0.3490 ms | 2.0Ă— |
| 100 KB | 1.4855 ms | 1.4145 | 1.4093 | 3.8399 ms | 2.6Ă— |
| 1 MB | 14.97 ms | 15.03 | 14.96 | 59.37 ms | 4.0Ă— |
| 10 MB | 158.10 ms | 165.25 | 162.86 | 2,927.89 ms | 18.5Ă— |
p50 milliseconds, median of three runs. parser-bench.json. The three Python parsers ran in one process; cheerio ran in Node 22, which is a runtime boundary as well as a library one — see below.
Reading that table honestly

The 1 KB row is noise. Across the three Python parsers the spread at that size is 77.6%, and individual runs overlap wildly — selectolax ranged 0.0267 to 0.0404 ms across its three runs. At 28 microseconds, timer resolution and scheduling dominate. I would not rank anything at 1 KB, including cheerio.
The middle of the table is unremarkable. 2Ă— to 4Ă— on pages between 10 KB and 1 MB. For a scraper doing a few hundred pages, that is 45 milliseconds a page instead of 15, and you will never see it.
The 10 MB row is not noise. cheerio's three runs came in at 2,839, 2,928 and 2,954 ms — tight, and well separated from the other rows. The 10 MB end-to-end result departs sharply from the smaller-size pattern. Five size points do not establish asymptotic complexity or identify which runtime, parser, selector, allocation, or garbage-collection layer causes the jump.
It lands in BeautifulSoup's band. The published bench measured four more parsers on the same 10 MB fixture, and putting cheerio's 2,927.89 ms beside them is the most useful thing in this article:
| Parser (10 MB) | p50 |
|---|---|
| selectolax (lexbor) | 159.93 ms |
| lxml | 172.93 ms |
| parsel | 231.85 ms |
| selectolax (modest) | 247.95 ms |
| BeautifulSoup + lxml | 2,261.56 ms |
| BeautifulSoup + html.parser | 2,788.75 ms |
| cheerio | 2,927.89 ms |
The four Python rows are the published figures from bench_parse.json; cheerio's is from this run. The reference parser reproduced within 0.989×–1.079× between the two runs, so treat differences smaller than about 8% as inside that uncertainty — cheerio against BeautifulSoup's html.parser backend (5% apart) is inside it, cheerio against selectolax (18×) is not.
BeautifulSoup is the library people reach for when they want convenience and knowingly accept it is slow — it is the one every Python performance thread tells you to replace. On a 10 MB document, cheerio sits at the bottom of that same band, not in the band of the C-backed parsers it tends to get filed alongside.
The Node-side replacement question remains open in this article. Newer Node alternatives were not tested, so this result cannot say that swapping libraries is impossible or dismiss them as less established. It only shows the measured cheerio path against the listed Python stacks.
It is a runtime comparison as well as a library one. cheerio's milliseconds come from Node's JIT and garbage collector; the others come from CPython calling into C. The content hash proves the same work was done, and both numbers are what a developer choosing a stack actually experiences — but nobody should read this as "cheerio's algorithm is 18× worse than selectolax's". It is what happened, on this machine, in each library's native runtime.
Setup reality
| Library | Packages | Disk | Licence | Stars | Last push |
|---|---|---|---|---|---|
| cheerio | 22 (npm) | 9.0 MiB | MIT | 30,449 | 2026-08-11 |
| PyQuery | 3 (pip) | 20.1 MiB | BSD | 2,380 | 2026-07-27 |
Official reference: Cheerio configuration documentation.
metadata-snapshot.json, fetched the day of writing.
npm install cheerio took under two seconds and pulled 9.0 MiB. Cold import measured 0.056 s in a separate converter run on the same machine.
Eleven direct dependencies is a lot for a parser, and worth knowing if you audit your tree: htmlparser2, parse5, parse5-htmlparser2-tree-adapter, parse5-parser-stream, domhandler, domutils, dom-serializer, cheerio-select, encoding-sniffer, undici and whatwg-mimetype. Two full parser implementations ship in there, because cheerio can use either depending on what you ask it for.
Thirty thousand stars and a push the day before testing is as healthy as maintenance signals get in this category.
Memory, and what broken HTML does to it
Memory footprint and malformed-input behavior affect deployment and failure handling, so both are measured separately here.
The broader stress-test context is in the ten-library memory and malformed-HTML comparison.
Peak resident memory, via /usr/bin/time -l, one fresh process per cell — the import floor is what the library costs loaded and idle, the peaks include the document.
| Library | Runtime | Import floor | 226 KB peak | 10 MB peak |
|---|---|---|---|---|
| html2text | python3.14 | 18.7 | 19.9 | 71.2 |
| pyquery | python3.14 | 30.3 | 33.9 | 172.5 |
| resiliparse | python3.14 | 20.5 | 25.1 | 225.1 |
| markdownify | python3.14 | 23.9 | 28.9 | 278.5 |
| goose3 | python3.14 | 44.1 | 52.4 | 398.5 |
| cheerio | node22 | 66.8 | 76.5 | 398.5 |
| justext | python3.14 | 30.3 | 36.6 | 431.2 |
| newspaper4k | python3.14 | 52.6 | 61.8 | 668.5 |
| trafilatura | python3.14 | 52.5 | 64.8 | 927.1 |
| turndown | node22 | 47.8 | 68.4 | 2947.1 |
memory-results.json. Python and Node baselines are not comparable to each other; the interpreter is inside both.
cheerio has the highest import floor in this mixed context table at 66.8 MiB, including the Node runtime and dependencies. Its process peaked at 398.5 MiB on the 10 MB fixture. The other rows include parsers, converters, and article extractors doing different primary work, so use them as process-footprint context rather than peer-performance rankings. The same-runtime turndown row peaked much higher, but it performs conversion rather than the title-and-href selector contract measured for cheerio.
Broken HTML. Twelve documents each breaking exactly one thing — unclosed tags, mis-nested inline elements, unquoted attributes with spaces, stray closers, no <html> at all, duplicate attributes, a document truncated mid-tag, bad entities, an unclosed <script>, a lying charset declaration, a comment containing markup, and 600 levels of nesting — plus two well-formed controls at matched sizes, because "it returned nothing" only says something about malformedness if the library is not also silent on a clean document of the same size.
cheerio raised on 0 of 14 and returned nothing on 0, recovering 11/22 scored sentinels across the malformed fixtures (malformed-results.json). For parsers, the scorer checks heading and link sentinels across eleven scorable malformed documents; it does not score the paragraph sentinel, and the unclosed-<script> fixture is excluded. Without a same-contract baseline in this section, 11/22 is not a quality ranking. The supported conclusion is that cheerio returned non-empty output without raising on all fourteen malformed-plus-control inputs, while recovering half of the scored markers.
Pros and cons
In its favour. Familiar jQuery syntax. MIT. A timestamped maintenance signal of 30,449 stars and repository activity the day before testing. Two parser backends and encoding-related packages are present, although backend recovery and encoding accuracy were not isolated here. The sorted title-plus-href hash matched the reference at every fixture size.
Against it. 18.5Ă— slower than selectolax on a 10 MB document, and 4Ă— on 1 MB. Eleven direct dependencies, including two complete parser implementations. Node only. And nothing in its docs suggests a size at which it stops being the obvious choice.
Who should use it, and who should not
Use cheerio if you are in Node, the jQuery-shaped API is valuable, and representative pages resemble the tested sizes up to 1 MB. One megabyte is the largest tested point before the sharp 10 MB jump; this article does not establish the cutoff between them or claim how much of the web falls below it.
Benchmark before using it for very large HTML documents such as generated reports, catalogue dumps, or long list pages. XML sitemap behavior was not tested. On the 10 MB HTML fixture, 2.9 seconds per document is a material cost that compounds.
If you are in Python, this comparison says something else: selectolax, lxml and PyQuery are effectively tied from 10 KB upward (within 0.5% to 5.4%, with overlapping run ranges), so pick on API rather than speed. cheerio's gap to all three is the interesting number, not the differences among them.
Where a managed API fits
cheerio parses HTML you already have. It does not fetch, render JavaScript, or handle an anti-bot layer — and on many real targets that is the harder half of the job.
A managed fetch/render/extraction service, including our own Thunderbit, sits at a different responsibility boundary. Thunderbit was not benchmarked here. The relevant distinction is parsing supplied HTML with selectors versus outsourcing acquisition, rendering, and extraction; this article provides no same-metric quality, latency, or cost comparison.
The fair framing: if you hold the HTML and know your selectors, cheerio is free and pleasant to use. If you are fetching pages at scale, or you would rather describe the data than the DOM, that is a different purchase.
For the wider field, our web scraping API roundup covers hosted options and the open-source scraper pillar the self-hosted ones. If the parsed output is headed for a model, converting HTML to Markdown in Python covers where fidelity gets lost.
Try Thunderbit for Web Data Extraction
Should you use cheerio?
Yes, in Node, when the API fit matters and representative documents stay near the tested small-to-1 MB range.
The API familiarity and current maintenance signals are legitimate selection inputs. The benchmark does not prove that a particular support answer exists or that the tested page-size distribution matches a production corpus.
The number to keep is the 10 MB one. Somewhere between 1 MB and 10 MB, cheerio's cost stops tracking the others and starts multiplying — 4× becomes 18.5×. If your corpus contains documents that large, benchmark before you commit, because nothing in the library will warn you.
Try Thunderbit for Web Data Extraction Get Started Free
FAQs
Is comparing cheerio against Python parsers fair? It is a stack comparison, not an algorithm comparison. All four produced identical sorted title text and hrefs under the hash rule on 5 of 5 page sizes. That does not prove total parser equivalence. cheerio's timings include Node's runtime behavior, while the others include CPython calling C-backed parsers; the comparison describes those end-to-end choices.
Why is the 1 KB row not ranked? Because at 28 microseconds the measurement is dominated by noise. Across three runs the Python parsers spread 77.6%, with individual runs overlapping each other. Any ordering at that size would be an artifact. The 10 KB row upward is stable enough to read.
What causes the jump at 10 MB? This test does not say. What it establishes is that the jump is real and not noise: cheerio's three runs landed at 2,839, 2,928 and 2,954 ms, well separated from everything else, while the 1 MB gap was 4Ă—. Isolating the cause would mean profiling cheerio's parser backends separately, which was out of scope here.
How many dependencies does it really have?
Eleven direct, 22 top-level after resolution, 9.0 MiB on disk. Two of those are complete parser implementations — htmlparser2 and parse5 — because cheerio can use either. That is the price of handling both forgiving and spec-compliant parsing, and it is worth knowing if you audit dependency trees.
What was not tested here?
The current draft did test process peak memory on one 226 KB and one 10 MB document, and a fourteen-input malformed-plus-control set where cheerio raised on none, returned non-empty output on all, and recovered 11/22 scored sentinels. It did not test streaming via parse5-parser-stream, encoding correctness, backend-specific recovery, the location of the performance jump between 1 and 10 MB, XML parsing, or newer Node alternatives. The raw relative artifact links also require the same public directory structure at publication time; otherwise they need durable public URLs or a repository commit reference.


