Web Scraping with Node.js
You need a list of prices, job posts or articles from a site that has no API. You write a quick axios.get(), load the HTML, query the selector you saw in DevTools, and get back an empty array. Or it works on day one and starts returning 403s by day three. Most scraping problems look like one of those two.
Node.js handles this kind of work well: lots of I/O, lots of waiting on network, and good libraries for both HTTP and DOM parsing. This post walks through Cheerio for server-rendered pages and Puppeteer for pages that build their content with JavaScript, then covers retries, concurrency limits, exporting data, and the things that usually break once a scraper runs on a schedule.
Check for an official API first. GitHub and OpenWeatherMap, for example, have free tiers, and an API won't break when someone redesigns a page. Scrape when there's no API, the API costs too much, or it doesn't expose the data you need. Read the site's robots.txt and Terms of Service before you start.
Choosing the Right Tool
The choice depends on one question: is the data in the HTML the server sends, or does JavaScript add it later? Open the page with "View Source" (not DevTools, which shows the rendered DOM). If your data is there, you don't need a browser.
| Library | Approach | Best For | Speed |
|---|---|---|---|
| Cheerio | Parse raw HTML (server-rendered) | Static pages, blogs, news sites | Very fast (no browser) |
| Puppeteer | Headless Chrome (full browser) | SPAs, React/Vue apps, login-gated pages | Slower (real browser) |
| Playwright | Multi-browser headless automation | Cross-browser, complex auth flows | Moderate |
| axios + Cheerio | HTTP + DOM parsing | Simple HTML extraction at scale | Fastest |
Static vs Dynamic scraping pipelines in Node.js
Setting Up Your Project
Initialise the project
Create a Node.js project and install the packages for both static and dynamic scraping.
mkdir node-scraper && cd node-scraper
npm init -y
# Static scraping
npm install axios cheerio
# Dynamic scraping (downloads Chromium automatically)
npm install puppeteer
# Utilities
npm install fs-extra csv-writer p-limit
Configure project structure
Keep the scrapers, the HTTP client and the export helpers in separate files so you can reuse them across targets.
node-scraper/
├── scrapers/
│ ├── cheerio-scraper.js # Static HTML scraping
│ └── puppeteer-scraper.js # Dynamic JS-rendered scraping
├── utils/
│ ├── http.js # Axios instance with retry logic
│ └── export.js # CSV / JSON export helpers
├── data/ # Output directory
└── index.js # Entry point
Static Scraping with Cheerio
If you've used jQuery, you already know most of Cheerio. It parses an HTML string and gives you $('selector') without running any JavaScript or loading a browser. That makes it fast and cheap, and it's what I reach for first on news articles, docs, product listings and anything else the server sends as complete HTML.
Basic Cheerio Scraper
const axios = require('axios');
const cheerio = require('cheerio');
async function scrapeArticles(url) {
const { data: html } = await axios.get(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (compatible; MyCrawler/1.0; +https://example.com)',
},
timeout: 10_000,
});
const $ = cheerio.load(html);
const articles = [];
// Iterate each article card in the listing
$('article.post-card').each((_, el) => {
articles.push({
title: $(el).find('h2.post-title').text().trim(),
url: $(el).find('a.post-link').attr('href'),
date: $(el).find('time').attr('datetime'),
summary: $(el).find('p.excerpt').text().trim(),
tags: $(el)
.find('.tag')
.map((_, t) => $(t).text().trim())
.get(),
});
});
return articles;
}
module.exports = { scrapeArticles };
Handling Pagination
async function scrapeAllPages(baseUrl, maxPages = 10) {
const allItems = [];
let page = 1;
while (page <= maxPages) {
const url = `${baseUrl}?page=${page}`;
console.log(`Scraping page ${page}: ${url}`);
const { data: html } = await axios.get(url, { timeout: 10_000 });
const $ = cheerio.load(html);
// Collect items on this page
$('li.result-item').each((_, el) => {
allItems.push({
name: $(el).find('.name').text().trim(),
price: parseFloat($(el).find('.price').text().replace(/[^0-9.]/g, '')),
sku: $(el).data('sku'),
});
});
// Stop if there is no "next" link
const hasNext = $('a[rel="next"]').length > 0;
if (!hasNext) break;
page++;
// Polite delay so we don't hammer the server
await new Promise(r => setTimeout(r, 1500));
}
return allItems;
}
Firing requests back to back can overload a small server and is the fastest way to get your IP blocked. Add at least a 1 to 2 second delay between requests to the same host. Anything that runs regularly should go through a queue with a concurrency limit.
Dynamic Scraping with Puppeteer
When Cheerio comes back empty because the page is a React or Vue app, you need something that runs the JavaScript. Puppeteer drives a real Chromium instance, so it can fill login forms, click buttons, wait for API calls to finish and read the DOM after client-side rendering. The cost is memory and speed: each browser uses far more resources than an HTTP request.
Core Puppeteer Patterns
const puppeteer = require('puppeteer');
async function scrapeWithPuppeteer(url) {
const browser = await puppeteer.launch({
headless: 'new', // use new headless mode
args: ['--no-sandbox'],
});
const page = await browser.newPage();
// Set a realistic viewport and User-Agent
await page.setViewport({ width: 1280, height: 800 });
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
await page.goto(url, { waitUntil: 'networkidle2' });
// Extract data in browser context
const products = await page.$$eval('.product-card', cards =>
cards.map(card => ({
name: card.querySelector('.name')?.textContent.trim(),
price: card.querySelector('.price')?.textContent.trim(),
rating: card.querySelector('.stars')?.dataset.rating,
}))
);
await browser.close();
return products;
}
// Wait for a selector to appear
await page.waitForSelector('.results-container', { timeout: 15_000 });
// Wait for a network request to finish
await page.waitForResponse(res =>
res.url().includes('/api/products') && res.status() === 200
);
// Wait for JavaScript variable to be set
await page.waitForFunction(
() => window.__NEXT_DATA__?.props?.pageProps?.items?.length > 0
);
// Wait for navigation after a click
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle0' }),
page.click('button.load-more'),
]);
// Scroll to bottom to trigger infinite scroll
await page.evaluate(async () => {
await new Promise(resolve => {
let total = 0;
const step = 400;
const timer = setInterval(() => {
window.scrollBy(0, step);
total += step;
if (total >= document.body.scrollHeight) {
clearInterval(timer);
resolve();
}
}, 200);
});
});
async function loginAndScrape(url, credentials) {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
// Navigate to login page
await page.goto('https://example.com/login');
// Fill in credentials
await page.type('#email', credentials.email, { delay: 50 });
await page.type('#password', credentials.password, { delay: 50 });
// Submit and wait for redirect
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle0' }),
page.click('button[type="submit"]'),
]);
// Verify we are logged in
const loggedIn = await page.$('.user-dashboard');
if (!loggedIn) throw new Error('Login failed');
// Now navigate to the target URL
await page.goto(url, { waitUntil: 'networkidle2' });
const data = await page.$$eval('.protected-item', items =>
items.map(i => i.textContent.trim())
);
await browser.close();
return data;
}
// Intercept XHR/fetch responses to grab API data directly
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
// Collect API responses before navigation
const apiData = [];
page.on('response', async response => {
const url = response.url();
if (url.includes('/api/v2/listings') && response.status() === 200) {
try {
const json = await response.json();
apiData.push(...(json.results ?? []));
} catch {}
}
});
await page.goto('https://example.com/listings', {
waitUntil: 'networkidle0',
});
console.log('Captured API items:', apiData.length);
await browser.close();
Building a Production-Ready Scraper
A one-off script can crash halfway and you just run it again. A scraper on a cron job can't. It needs retries for flaky responses, a cap on concurrent requests, and a way to write results somewhere useful.
HTTP Client with Retry Logic
const axios = require('axios');
const client = axios.create({
timeout: 15_000,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language': 'en-US,en;q=0.9',
Accept: 'text/html,application/xhtml+xml',
},
});
// Automatic retry on 429 / 5xx with exponential back-off
client.interceptors.response.use(null, async error => {
const config = error.config;
config._retryCount = config._retryCount ?? 0;
const retryableStatus = [429, 500, 502, 503, 504];
const shouldRetry =
config._retryCount < 3 &&
(!error.response || retryableStatus.includes(error.response.status));
if (!shouldRetry) return Promise.reject(error);
config._retryCount += 1;
const delay = 1000 * 2 ** config._retryCount; // 2s, 4s, 8s
console.log(`Retry ${config._retryCount} for ${config.url} in ${delay}ms`);
await new Promise(r => setTimeout(r, delay));
return client(config);
});
module.exports = client;
Concurrent Scraping with Rate Limiting
const pLimit = require('p-limit');
const client = require('./utils/http');
const cheerio = require('cheerio');
// Max 3 concurrent requests at once
const limit = pLimit(3);
async function scrapeProduct(url) {
const { data: html } = await client.get(url);
const $ = cheerio.load(html);
return {
url,
title: $('h1.product-title').text().trim(),
price: $('[data-price]').data('price'),
description: $('div.product-description').text().trim().slice(0, 500),
images: $('img.product-image')
.map((_, el) => $(el).attr('src'))
.get(),
};
}
async function scrapeAll(urls) {
const tasks = urls.map(url =>
limit(async () => {
try {
return await scrapeProduct(url);
} catch (err) {
console.error(`Failed: ${url}: ${err.message}`);
return null;
}
})
);
const results = await Promise.all(tasks);
return results.filter(Boolean); // remove nulls from failures
}
module.exports = { scrapeAll };
From version 4, p-limit is ESM only. On Node versions older than 22.12, require('p-limit') fails with ERR_REQUIRE_ESM. Either switch your project to ES modules and use import pLimit from 'p-limit', or pin p-limit@3, which still ships CommonJS.
Exporting Data to CSV and JSON
const { createObjectCsvWriter } = require('csv-writer');
const fs = require('fs-extra');
const path = require('path');
async function saveJSON(data, filename) {
await fs.ensureDir('./data');
const filePath = path.join('./data', filename);
await fs.writeJSON(filePath, data, { spaces: 2 });
console.log(`Saved ${data.length} records → ${filePath}`);
}
async function saveCSV(data, filename, headers) {
await fs.ensureDir('./data');
const writer = createObjectCsvWriter({
path: path.join('./data', filename),
header: headers, // [{ id: 'title', title: 'Title' }, ...]
});
await writer.writeRecords(data);
console.log(`CSV written → ./data/${filename}`);
}
module.exports = { saveJSON, saveCSV };
Anti-Detection and Ethical Practices
A lot of sites now run some kind of bot detection, from simple User-Agent checks to services like Cloudflare. Knowing what they look for helps you avoid getting blocked by accident, and it's also a good reminder of where the line is between collecting public data and abusing someone's server.
Common Anti-Detection Techniques
- Rotate User-Agents: Use a pool of real browser User-Agent strings and pick one randomly per request.
- Respect
robots.txt: Parse/robots.txtand honourDisallowpaths. Use therobots-parsernpm package. - Use Puppeteer-extra stealth plugin: Patches dozens of Puppeteer fingerprinting vectors (
navigator.webdriver, Chrome-specific globals, etc.). - Add realistic delays: Randomise delays between
800msand3000msto mimic human browsing. - Handle CAPTCHAs gracefully: Detect
captchain the response URL or body, pause, and log it for manual review. If you're hitting CAPTCHAs constantly, treat it as a sign you're going too fast. - Use residential proxies for scale: Rotate IPs through a proxy pool to avoid IP-level rate limits.
// npm install puppeteer-extra puppeteer-extra-plugin-stealth
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
// Randomise viewport to avoid static fingerprint
await page.setViewport({
width: 1280 + Math.floor(Math.random() * 100),
height: 800 + Math.floor(Math.random() * 100),
});
// Pass the navigator.webdriver check
await page.evaluateOnNewDocument(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
await page.goto('https://example.com', { waitUntil: 'networkidle2' });
Read the site's Terms of Service. Scraping personal data can put you in breach of GDPR or CCPA. Don't scrape at a rate that slows the site down for real users. Keep only the fields you need and delete them when you're done. If the data feeds a commercial product, talk to a lawyer first.
Real-World Example: Job Listing Aggregator
Here's everything wired together: collect job URLs from five listing pages of a static job board, scrape each job with two requests in flight at a time, and save the results as JSON and CSV.
const client = require('./utils/http');
const cheerio = require('cheerio');
const { saveJSON, saveCSV } = require('./utils/export');
const pLimit = require('p-limit');
const BASE = 'https://jobs.example.com';
const limit = pLimit(2);
async function getListingUrls(page = 1) {
const { data: html } = await client.get(`${BASE}/jobs?page=${page}`);
const $ = cheerio.load(html);
return $('a.job-link')
.map((_, el) => BASE + $(el).attr('href'))
.get();
}
async function scrapeJob(url) {
await new Promise(r => setTimeout(r, 800 + Math.random() * 1200));
const { data: html } = await client.get(url);
const $ = cheerio.load(html);
return {
title: $('h1.job-title').text().trim(),
company: $('span.company').text().trim(),
location: $('span.location').text().trim(),
salary: $('span.salary').text().trim() || 'Not disclosed',
posted: $('time').attr('datetime'),
description: $('div.job-description').text().trim().slice(0, 1000),
url,
};
}
async function run() {
console.log('Starting job scraper...');
const urls = [];
for (let p = 1; p <= 5; p++) {
const pageUrls = await getListingUrls(p);
urls.push(...pageUrls);
await new Promise(r => setTimeout(r, 1500));
}
console.log(`Found ${urls.length} job listings`);
const jobs = await Promise.all(
urls.map(url => limit(() => scrapeJob(url).catch(() => null)))
);
const valid = jobs.filter(Boolean);
await saveJSON(valid, 'jobs.json');
await saveCSV(valid, 'jobs.csv', [
{ id: 'title', title: 'Title' },
{ id: 'company', title: 'Company' },
{ id: 'location', title: 'Location' },
{ id: 'salary', title: 'Salary' },
{ id: 'posted', title: 'Posted' },
{ id: 'url', title: 'URL' },
]);
console.log(`Done! Scraped ${valid.length} jobs.`);
}
run().catch(console.error);
Debugging and Troubleshooting
Common Scraping Problems and Fixes
| Problem | Likely Cause | Fix |
|---|---|---|
| Empty Cheerio results | Content rendered by JavaScript | Switch to Puppeteer |
| 403 Forbidden | Missing User-Agent or blocked IP | Set realistic headers, rotate proxies |
| CAPTCHA page returned | Too many requests / bot detection | Slow down, use stealth plugin |
| Timeout errors | Slow server or waitUntil never fires |
Increase timeout, use domcontentloaded |
| Selectors stop working | Site redesigned its DOM | Add monitoring, use resilient selectors (data-* attrs) |
| Memory leak in Puppeteer | Not closing pages/browsers | Always call browser.close() in finally |
Debugging Puppeteer Visually
// Run in headed mode to watch what's happening
const browser = await puppeteer.launch({
headless: false, // show the browser window
slowMo: 250, // slow down each action by 250ms
devtools: true, // open DevTools automatically
});
// Take a screenshot to debug the current page state
await page.screenshot({ path: 'debug.png', fullPage: true });
// Dump the full rendered HTML
const html = await page.content();
require('fs').writeFileSync('debug.html', html);
What to check before running a scraper on a schedule
Getting a scraper to work once is the easy part. These are the things that tend to bite later:
Before it goes on a cron job
- Look for a JSON source first: Many "dynamic" pages load their data from an internal API, or embed it in a
<script id="__NEXT_DATA__">tag. Reading that with axios is faster and breaks less often than driving a browser. - Fail loudly on empty results: When a site changes its markup, selectors don't throw. They just return empty strings, and you happily save a file full of blanks. Check that key fields aren't empty and alert when the record count drops sharply.
- Close browsers in
finally: The examples above close the browser on the happy path only. In a long-running process, one thrown error per run is enough to leave orphaned Chromium processes eating memory. - Plan for Docker: Puppeteer in a slim container usually fails at launch because of missing system libraries, and running as root gives you
Running as root without --no-sandbox is not supported. Use an image that already has Chrome's dependencies, and prefer a non-root user over disabling the sandbox.
When wouldn't I use this? If an official API exists, I use it, even if it's a bit limited. I'd also skip Puppeteer for anything high-volume unless there's truly no other way to get the data; a headless browser per page gets expensive quickly. And if a site is clearly working hard to block scrapers, or the data is personal, that's usually a sign to ask for access or find another source rather than reach for stealth plugins and proxies.