Four Node.js libraries cover almost every JavaScript scraping case in 2026. fetch + Cheerio handles static HTML. playwright runs the browser when a page builds its content with JavaScript. The playwright-extra plus puppeteer-extra-plugin-stealth combination closes the automation leaks default headless Chromium ships with. Crawlee covers the cases where one scraper turns into a multi-page crawler with queues and retries.
Everything else on the npm registry is either an alternative to one of the four, or a library you can safely skip in 2026.
Common Takeaways:
fetch + cheerioparses a typical static HTML page in 8 ms median with about 28 MB of heap.playwrightcold-starts headless Chromium in 251 ms, 4x faster than Puppeteer and 2.3x lighter on RAM.puppeteer-extra-plugin-stealththroughplaywright-extrabrings sannysoft from 5 failures to 0.Crawleebundles request queue, retries, proxy rotation, and a fingerprint injector for multi-page crawls.- Skip
x-ray,nightmare,osmosis, andselenium-webdriverfor JavaScript-only projects.
You’ll need Node 22+ and basic comfort with async/await. Every library covered here ships TypeScript types.
How to pick a JavaScript scraping library
The library you need depends on three questions in order. Each answer narrows the shortlist.
The first question is whether the page is static or dynamic. Static means the HTML returned by fetch already contains the data you want. Dynamic means the data shows up only after JavaScript runs in a browser. Open view-source in Chrome and search for the data you want to scrape. If it’s there, the page is static.

The second question is how many pages you need to hit. One scraper-script for a single endpoint is different from a multi-page crawler with queues, retries, and dedup. Standalone libraries cover the first case. Crawlee covers the second.
The third question is anti-bot pressure. Default headless Chromium fails 5 of 20 sannysoft fingerprint checks in 2026. If your target inspects browser fingerprints (Cloudflare, PerimeterX, DataDome), you need the stealth plugin on top of Playwright. If your IPs are getting blocked, you need residential proxies on top of that. If both fail, the trade-off shifts toward a managed scraping API.

The matrix below maps each common case to the library combo that handles it.
| Use case | Recommended library combo |
|---|---|
| Static HTML, single page | fetch + Cheerio |
| Static HTML, hundreds of pages | Crawlee (CheerioCrawler mode) |
| JS-rendered page exposes XHR/API | fetch directly to the endpoint |
Structured data in HTML (JSON-LD, __NEXT_DATA__, inline var data) | fetch + Cheerio to extract from <script> |
| JS-rendered, no JSON shortcut | Playwright |
| JS-rendered, anti-bot pressure | Playwright + playwright-extra + stealth plugin + residential proxies |
| Need page’s own JS without a real browser | jsdom |
| Already on a Chromium codebase | Puppeteer |
Comparison table
| Library | Best for | Cost | dl/wk | Last release |
|---|---|---|---|---|
fetch (built-in) | Static HTML in Node 22+ | None | Built in | Bundled with Node 22 |
axios | HTTP with interceptors, proxy rotation | One dep | 122M | 2026-06-14 |
cheerio | Parse and extract from static HTML | 8 ms, +28 MB heap | 37M | 2026-01-23 |
jsdom | Parse + run inline page JS | 60 ms, +83 MB heap | 77M | 2026-04-30 |
playwright | JS-rendered pages, anti-bot | 251 ms cold-start, 167 MB | 62M | 2026-06-15 |
puppeteer | Existing Puppeteer codebase | 1004 ms cold-start, 379 MB | 11M | 2026-05-26 |
playwright-extra + puppeteer-extra-plugin-stealth | Defeat headless fingerprint | Stacks on Playwright/Puppeteer | 1M | 2023-03-01 |
crawlee | Multi-page crawler | Wraps Cheerio/Playwright | 130K | 2026-06-04 |
playwright-extra plus stealth has the only stalled release date in this set. The plugin still passes the sannysoft test in 2026. If Chromium changes a detectable surface and the plugin doesn’t follow, replace the patches with custom addInitScript calls or move fingerprint off-host.
1. Fetch
Node 22 ships with fetch built in. For static scraping that handles the HTTP side, and you don’t install anything.
A minimal GET against books.toscrape.com looks like this.
const res = await fetch('https://books.toscrape.com', {
headers: {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
'accept-language': 'en-US,en;q=0.9',
},
});
if (!res.ok) throw new Error(`status ${res.status}`);
const html = await res.text();Two notes on this snippet. First, the user agent. Many servers return a stripped page or a 403 against Node’s default fetch UA, so a real desktop UA gets you the same HTML view-source shows in Chrome. Second, native fetch has no retries. If the server returns 503 once you get nothing, so for multi-page work wrap the call in a retry-on-5xx loop with exponential backoff.
node-fetch was the polyfill for Node ≤ 17 and you don’t need it in 2026.
2. Axios
axios adds four capabilities native fetch doesn’t have built in.
| What axios gives | Native fetch equivalent |
|---|---|
| Interceptors fire on every request, the natural place to rotate proxies, attach cookies, or log URLs | Wrap every call site manually |
axios.get(url).then(r => r.data) returns parsed JSON | await res.json() per call |
Proxy config is one object passed to axios.create({ proxy: { host, port, auth } }) | undici.Agent with a proxy dispatcher |
| AbortSignal passes through interceptors | Thread signal into every call site |
For a one-off fetch + Cheerio script, native fetch is enough. For a long-lived scraper with proxy rotation and retry hooks, axios is less code overall.
got covers the same ground as axios with different ergonomics. request was deprecated in 2020.
3. Cheerio
cheerio is the lightest way to extract data from static HTML in Node.js. It loads an HTML string, exposes CSS and jQuery selectors, and returns parsed content in a single-digit number of milliseconds. On the books.toscrape.com catalog page, Cheerio parses and extracts every book in 8 ms median and uses about 28 MB of heap across 20 iterations. jsdom on the same page is 60 ms and 83 MB, which is 7x slower and 3x heavier, because it builds a spec-compliant DOM. Playwright on the same task launches a Chromium process that occupies 167 MB before you load a single page. Cheerio doesn’t execute JavaScript, so anything the page builds client-side after load is invisible to it.
Scraping the books.toscrape.com catalog looks like this.
import * as cheerio from 'cheerio';
const res = await fetch('https://books.toscrape.com');
const $ = cheerio.load(await res.text());
const books = $('article.product_pod').map((_, el) => ({
title: $(el).find('h3 a').attr('title'),
price: $(el).find('.price_color').text().trim(),
rating: ($(el).find('.star-rating').attr('class') || '').replace('star-rating ', ''),
})).get();
console.log(books.slice(0, 2));The pattern across most Cheerio scrapers stays this short. Find the repeating element, .map() over it, pull each field with a CSS selector and .text() or .attr().
Cheerio extracts whatever sits in the HTML you pass it. Server-rendered React (Next.js, Nuxt), classic e-commerce listings, blog posts, news articles, and JSON-LD in <script type="application/ld+json"> tags all qualify because the data is in the raw HTML response. Pages that build their content with JavaScript after load do not. The selectors return empty arrays. View-source on the target first.
4. jsdom
jsdom is a JavaScript implementation of the DOM and HTML standards. It parses an HTML string into a full Window/Document object you can query like a browser, and it can run the page’s own inline scripts. The benchmark from the Cheerio section measured it at 60 ms per parse and 83 MB of heap across 20 iterations, 7x slower and 3x heavier than Cheerio because of that DOM compliance.
| Capability | cheerio | jsdom | Playwright |
|---|---|---|---|
| Parse HTML to DOM | ✓ | ✓ | ✓ |
| CSS selector queries | ✓ | ✓ | ✓ |
Run inline <script> tags | ✗ | ✓ (in-process) | ✓ (real browser) |
| Network requests from the page | ✗ | ✗ | ✓ |
| Layout, fonts, real rendering | ✗ | ✗ | ✓ |
| Memory cost per parse | ~28 MB | ~83 MB | 167 MB Chromium process |
In return, jsdom does one thing Cheerio cannot. It executes the page’s JavaScript. If the data you need shows up only after a small inline script runs (a token decoder, an email deobfuscator, an inline template that hydrates from __NEXT_DATA__), jsdom can run that script without launching Chromium.
import { JSDOM } from 'jsdom';
const html = `<!doctype html>
<div id="email"></div>
<script>
document.getElementById('email').textContent =
atob('aGVsbG9AZXhhbXBsZS5jb20=');
</script>`;
const dom = new JSDOM(html, { runScripts: 'dangerously' });
console.log(dom.window.document.getElementById('email').textContent);The output:
hello@example.comThe runScripts: 'dangerously' flag opts into inline script execution. jsdom runs whatever the HTML hands it in the same Node process, so on untrusted input isolate the call inside a worker or sandbox.
For most static scraping, the trade isn’t worth it. Cheerio is faster and lighter, and Playwright handles the JS-rendered cases jsdom cannot (anything that depends on network, real rendering, or a fetch from inside the page). jsdom is the right pick when the page is static HTML plus a small inline script, and you don’t want to add Chromium to your container image.
5. Playwright
playwright is the default headless browser for JavaScript scraping in 2026. I ran the cold-start test on a fresh Node process per launch (5 runs, median reported). Chromium comes up in 251 ms and uses 167 MB of RSS before the first page load. That’s 4x faster cold-start and 2.3x less RAM than Puppeteer (same benchmark, full numbers in the next section). It runs a real Chromium, so JavaScript runs, fonts render, requests look like a browser’s, and the DOM you scrape is the one a user would see.
Here’s a Playwright scraper against quotes.toscrape.com/js, a page that renders its quotes via inline JavaScript. The npm install only ships the driver, so run npx playwright install chromium first to pull Chromium into the local cache.
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://quotes.toscrape.com/js/');
const quotes = await page.locator('.quote').evaluateAll(els =>
els.map(el => ({
text: el.querySelector('.text').textContent,
author: el.querySelector('.author').textContent,
}))
);
console.log(quotes.slice(0, 2));
await browser.close();The locator API auto-waits. Calls on page.locator('.quote') like .evaluateAll() block until the matching elements are present and stable, so most race conditions between page load and scrape disappear without writing manual waitForSelector calls.
Playwright also drives Firefox and WebKit out of the box. Most scrapers only need Chromium, but the option matters when a target’s rendering quirk only shows up in Firefox.
Default headless Chromium fails 5 of 20 sannysoft fingerprint checks in 2026. On a target with Cloudflare, PerimeterX, or DataDome, the page either won’t load or returns a CAPTCHA. The stealth plugin section below covers what to do about that.
6. Puppeteer
puppeteer is the same idea as Playwright with a different lineage. Google’s Chrome DevTools team built it first, and it drives only Chromium. In 2026 it’s slower and heavier on the same task. Cold-start runs at 1004 ms median (vs Playwright’s 251 ms) and the launched Chromium occupies 379 MB of RSS (vs Playwright’s 167 MB). Both numbers come from the same 5-run benchmark in the repo for this article.
| Metric | Playwright | Puppeteer | Ratio |
|---|---|---|---|
| Cold-start (ms, median of 5 runs) | 251 | 1004 | 4.0x |
| Chromium RSS at idle (MB) | 167 | 379 | 2.3x |
| Browsers supported | Chromium, Firefox, WebKit | Chromium only | n/a |
A minimal Puppeteer scraper looks almost identical to Playwright, with puppeteer.launch() and slightly different method names.
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://quotes.toscrape.com/js/');
const quotes = await page.$$eval('.quote', els =>
els.map(el => ({
text: el.querySelector('.text').textContent,
author: el.querySelector('.author').textContent,
}))
);
console.log(quotes.slice(0, 2));
await browser.close();For a new project, start on Playwright. The one reason to keep Puppeteer in 2026 is an existing codebase. I’d skip the migration unless something else forces the move. The API surface is similar enough that migration is several days of test rewrites for sub-second-per-launch wins, which rarely pays back on its own. If you’re starting on Puppeteer to pair with puppeteer-extra-plugin-stealth, the same plugin works against Playwright through playwright-extra (covered below).
7. Stealth plugin
Default Playwright fails 5 of 20 sannysoft fingerprint checks. With puppeteer-extra-plugin-stealth applied through playwright-extra, all 20 pass. These are the parts Cloudflare, PerimeterX, and DataDome read on first paint.
| Plugin patches | Default Chromium leak |
|---|---|
navigator.webdriver | Returns true instead of undefined |
chrome.runtime object | Missing properties a real Chrome ships |
| Permissions API | Inconsistent response to Notification.permission query |
navigator.plugins | Empty list |
iframe contentWindow | Wrong prototype chain |
navigator.deviceMemory | Suspiciously round value |
| UA string | Contains HeadlessChrome |
Command to install:
npm install playwright playwright-extra puppeteer-extra-plugin-stealthScript example:
import { chromium } from 'playwright-extra';
import Stealth from 'puppeteer-extra-plugin-stealth';
chromium.use(Stealth());
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://bot.sannysoft.com/');
await page.screenshot({ path: 'sannysoft.png', fullPage: true });
await browser.close();The same plugin chain works against Puppeteer with puppeteer-extra. The patches and the result are identical.
The plugin’s last npm release is from March 2023 and its last commit from July 2024. Maintenance has effectively stopped, but the patches still hold against the latest Chromium in 2026. If Chromium changes a detectable surface and the plugin doesn’t follow, the fallback is custom addInitScript patches or a managed scraping API that handles fingerprint upstream.
Stealth fixes the browser fingerprint and nothing else. Your IP and your behavior pass through untouched. If a target rate-limits by IP or watches for inhuman mouse/keyboard cadence, a clean fingerprint won’t help. Anti-bot in 2026 layers three checks. Fingerprint first, then IP, then behavior. Fixing one without the others gets you a slightly more polite rejection. The section on managed scraping APIs below covers what to do when all three layers fail.
8. Crawlee
crawlee is the framework for the case where one scraper turns into a multi-page crawler. It bundles a request queue, retry-on-failure, proxy rotation, auto-scaling concurrency, a session pool, and a fingerprint injector. You pick a crawler class to match the target and pass it a request handler.
| Crawler class | Wraps | Best for |
|---|---|---|
CheerioCrawler | fetch + cheerio | Static HTML at scale |
PlaywrightCrawler | Playwright | JS-rendered pages at scale |
PuppeteerCrawler | Puppeteer | Existing Chromium-only codebase |
HttpCrawler | raw HTTP | When the response is already JSON |
Here’s a CheerioCrawler against the books.toscrape.com catalog with pagination.
import { CheerioCrawler } from 'crawlee';
const crawler = new CheerioCrawler({
maxRequestsPerCrawl: 50,
async requestHandler({ $, request, enqueueLinks, pushData }) {
const books = $('article.product_pod').map((_, el) => ({
title: $(el).find('h3 a').attr('title'),
price: $(el).find('.price_color').text().trim(),
})).get();
await pushData({ url: request.url, count: books.length, books });
await enqueueLinks({ selector: '.next a' });
},
});
await crawler.run(['https://books.toscrape.com']);enqueueLinks walks the next-page links and adds them to the queue. pushData writes one JSON file per crawled page into ./storage/datasets/default/. maxRequestsPerCrawl caps the run so you don’t accidentally fetch 1000 pages on first try.
The PlaywrightCrawler swap is one import change and the same handler shape, with page instead of $. Crawlee bundles its own fingerprint injector that handles navigator.webdriver and the other tells the stealth section listed, so a PlaywrightCrawler doesn’t need playwright-extra on top.
For a one-shot scrape of three URLs, Crawlee is overkill. The setup cost only pays back when you need persistent state between runs, proxy rotation across hundreds of requests, or the auto-scaling concurrency.
Libraries to skip
A few libraries appear in older “best web scraping libraries” lists. Skip them.
| Library | Reason to skip | dl/wk | Last release |
|---|---|---|---|
x-ray | Replaced by Crawlee + CheerioCrawler | 3.5K | 2019-07-15 |
nightmare | Replaced by Playwright/Puppeteer | 12K | 2019-04-27 |
osmosis | Replaced by Cheerio + async loop | 120 | 2019-03-01 |
selenium-webdriver | Cross-language test stack, not JS-only | 2.1M | 2026-06-16 |
x-ray
x-ray was a Cheerio-plus-pagination wrapper. The last release shipped in July 2019. Crawlee with CheerioCrawler does the same job and is actively maintained.
nightmare
nightmare was Electron-based browser automation predating Puppeteer. Last release April 2019. Playwright and Puppeteer replaced it in the JS ecosystem.
osmosis
osmosis was a stream-based scraper. Last release March 2019, with 120 weekly downloads now. Modern Cheerio with async iteration covers the same use cases.
selenium-webdriver
selenium-webdriver is the one entry on this list that isn’t dead. It’s built for cross-language test infrastructure (Python, Java, Ruby, .NET). For a JavaScript-only stack, Playwright has a tighter API, faster cold-start, and 30x the npm install base. Pick Selenium only if you’re maintaining a polyglot test suite that already uses it.
Managed scraping APIs
When fingerprint patches and rotating proxies still get you blocked, the next layer up is a managed scraping API. HasData’s Web Scraping API handles JS rendering, residential proxies, fingerprint patches, and CAPTCHA solving in one endpoint. Pass a URL and get HTML or structured data back.
import * as cheerio from 'cheerio';
const res = await fetch('https://api.hasdata.com/scrape/web', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.HASDATA_API_KEY,
},
body: JSON.stringify({
url: 'https://quotes.toscrape.com/js/',
jsRendering: true,
proxyType: 'residential',
}),
});
const { content } = await res.json();
const $ = cheerio.load(content);
const quotes = $('.quote').map((_, el) => ({
text: $(el).find('.text').text(),
author: $(el).find('.author').text(),
})).get();
console.log(quotes.slice(0, 2));The managed API is worth the cost when blocking eats more developer time than building the scraper. If a custom stealth stack costs you a week a month chasing blocks, the API removes that week.
FAQ
Which JavaScript web scraping library is fastest?
cheerio for parsing, by a wide margin. Parsing the books.toscrape.com catalog takes 8 ms median in Cheerio and 60 ms in jsdom, about 7x slower for the same task. For dynamic pages where you need a browser, playwright cold-starts in 251 ms versus Puppeteer’s 1004 ms. Across all stages of scraping, the fastest stack is fetch + cheerio for static HTML and playwright for JS-rendered pages.
Cheerio vs jsdom, what’s the difference?
Cheerio parses HTML and exposes jQuery-style selectors. jsdom builds a spec-compliant DOM and can execute inline <script> tags in-process. On the same page, jsdom is roughly 7x slower and 3x heavier on memory than Cheerio. Use Cheerio when the data is already in the HTML. Use jsdom only when the page builds data via inline scripts and you don’t want to launch Chromium.
Playwright vs Puppeteer in 2026, which to pick?
Playwright for new projects. It cold-starts 4x faster than Puppeteer (251 ms vs 1004 ms), uses 2.3x less RAM (167 MB vs 379 MB), drives Firefox and WebKit in addition to Chromium, and has Microsoft maintenance. Keep Puppeteer only on an existing codebase, since the migration costs days of test rewrites for sub-second-per-launch wins.
Does default Playwright bypass Cloudflare?
No. Default headless Chromium fails 5 of 20 sannysoft fingerprint checks and gets blocked by Cloudflare, PerimeterX, and DataDome on first paint. Apply puppeteer-extra-plugin-stealth through playwright-extra and all 20 checks pass. If the target also rate-limits by IP, add residential proxies. If both fail, move to a managed scraping API.
Conclusion
The pick order on any new JS scraping target.
- Open view-source on the URL. If the data is there,
fetch + Cheeriois enough. - If the data is missing from view-source, check the network tab for an XHR or API endpoint that returns it. If found,
fetchdirectly to the JSON. - If there is no JSON shortcut, use
Playwrightfor the rendered DOM. - If default Playwright gets blocked on Cloudflare, PerimeterX, or DataDome, add
puppeteer-extra-plugin-stealththroughplaywright-extra. - If stealth plus a rotating proxy pool still gets blocked, switch to a managed scraping API.
- If the scraper crawls 100+ pages with retries, dedup, and persistent state, wrap any of the above in
Crawlee.


