← Back to Blog
web scraping with javascriptpuppeteer tutorialnodejs web scrapingapify actorscheerio scraping

Web Scraping with JavaScript: A Practical Guide for 2026

July 13, 2026

Web Scraping with JavaScript: A Practical Guide for 2026

You've probably hit the same wall most JavaScript developers hit the first time they try scraping a real site. The first script works on a simple page, then the next target returns empty HTML, the one after that rate-limits you, and the moment you try to run the job on a schedule, your “quick script” turns into infrastructure work.

That's the point where web scraping with JavaScript stops being a code snippet problem and starts becoming an engineering decision problem. Which pages can you fetch with a plain HTTP request? Which ones force you into browser automation? When should you inspect the Network tab instead of writing more selectors? And if the scraper becomes useful, how do you get it off your laptop and turn it into something reliable enough to run for clients, teammates, or paying users?

Table of Contents

Your Path to Mastering JavaScript Scraping

A common starting point looks like this. You need product listings, job posts, directory entries, or public pricing data. You know Node.js, you're comfortable with async code, and you assume scraping is mostly a matter of selecting the right HTML elements.

That assumption holds for about an afternoon.

Then you discover that “View Source” and “Inspect Element” don't match, some pages only populate after client-side rendering, and your first Promise.all() experiment gets you blocked. At that stage, the problem isn't JavaScript. It's choosing the right level of tooling for the page in front of you.

The practical path is narrower than most tutorials make it sound:

  1. Start light when the page is server-rendered and the HTML already contains the data.
  2. Escalate to a headless browser only when the content is injected by JavaScript or the flow requires interaction.
  3. Inspect the Network tab early so you don't automate a browser when the site is already calling a clean JSON endpoint.
  4. Design for deployment if the scraper matters beyond your machine.

Practical rule: Treat browser automation as a capability, not a default.

That mindset also helps when your scraper becomes part of a larger automation workflow. If your project needs browser-driven tasks beyond extraction, such as recorded flows or repeatable UI actions, the guide to RenderIO's video agents is useful because it shows how agent-style browser work is structured in production rather than as throwaway scripts.

There's also a business side to this skill. A scraper that reliably solves one narrow problem can become an internal tool, a client deliverable, or a marketplace product. If you want context on the broader range of automation tooling around this work, this overview of web automation tools is a practical companion.

Fast and Simple Scraping with Axios and Cheerio

The first choice in web scraping with JavaScript should usually be the cheapest one in compute, complexity, and maintenance. For static pages, that means Axios to fetch HTML and Cheerio to parse it.

Industry guidance describes a clear split in the JavaScript scraping stack: Axios is widely used for HTTP requests, Cheerio is the standard parser for static HTML extraction, and Puppeteer is the browser-based option for dynamic sites that require rendering, as outlined in Scrapingdog's guide to JavaScript web scraping.

Why this pair should be your default

Axios and Cheerio are fast because they don't pretend to be a browser. They request a page, load the returned HTML into a parser, and let you query it with CSS selectors. No browser startup, no rendering pipeline, no waiting for front-end code unless you require it.

That makes them a strong default for:

  • Catalog pages where listings are already in the HTML
  • Blogs and documentation that are rendered on the server
  • Internal pages where you control the markup
  • Prototypes where you need a baseline quickly

A laptop on a wooden desk showing JavaScript web scraping code to extract product data from websites.

A complete static scraper

Set up a small Node project:

  1. Create the folder

    • mkdir js-scraper
    • cd js-scraper
  2. Initialize the project

    • npm init -y
  3. Install dependencies

    • npm install axios cheerio

Create scrape.js:

import axios from "axios";
import * as cheerio from "cheerio";

async function scrapeProducts() {
  const url = "https://books.toscrape.com/";

  const response = await axios.get(url, {
    headers: {
      "User-Agent": "Mozilla/5.0",
    },
  });

  const $ = cheerio.load(response.data);
  const products = [];

  $("article.product_pod").each((_, element) => {
    const item = $(element);

    const title = item.find("h3 a").attr("title")?.trim() || null;
    const priceText = item.find(".price_color").text().trim() || null;
    const ratingClass = item.find(".star-rating").attr("class") || "";
    const rating = ratingClass.split(" ").find((part) => part !== "star-rating") || null;
    const relativeUrl = item.find("h3 a").attr("href") || null;

    products.push({
      title,
      priceText,
      rating,
      url: relativeUrl ? new URL(relativeUrl, url).href : null,
    });
  });

  console.log(products);
}

scrapeProducts().catch((error) => {
  console.error("Scrape failed:", error.message);
});

A junior developer usually asks why this code is structured this way instead of being shorter. The answer is durability.

  • headers help you avoid looking like a bare script.
  • Optional chaining and fallbacks keep one missing field from crashing the run.
  • Relative URL handling prevents broken links in your output.
  • A top-level catch gives you a failure mode you can log and debug.

What junior developers usually get wrong

The biggest mistake is trusting the selector logic too early. Before writing extraction code, inspect the HTML and verify that the data is present in the original response, not just in the browser's rendered DOM.

The second mistake is mixing extraction with cleanup. Extract raw values first, then normalize them. That separation makes debugging much easier when the page structure changes.

If the page already contains the data in returned HTML, don't launch a browser. You're paying complexity for no benefit.

Tackling Dynamic Content with Puppeteer

The Axios and Cheerio approach breaks the moment the target page ships an empty shell and fills it with JavaScript after load. You request the page, inspect the response, and the data isn't there. The browser sees it later. Your HTTP client never does.

That's when Puppeteer earns its keep. It launches a real browser instance, executes client-side code, and lets you scrape the rendered DOM instead of the initial response.

How to tell when static scraping has failed

You don't need guesswork. Open the target page and compare two things:

  • View Page Source
  • Inspect Element

If the source is mostly placeholders, script tags, or containers with no meaningful data, while the inspector shows complete content, you're dealing with client-side rendering. A static parser won't solve that.

This is why browser automation remains necessary for many modern sites. It's also where developers tend to over-simplify waiting logic. Browser automation isn't just “open page, then scrape.” You need to wait for the right state.

Bright Data notes that headless browsers like Puppeteer and Playwright can reach a 96.5% success rate on residential proxies with full JavaScript rendering, dropping to 93.4% under aggressive anti-bot fingerprinting, with response times increasing from 1 second to 3.6 seconds because of rendering and proxy rotation overhead. The same analysis notes that waiting for specific DOM events and using selector hierarchies reduces extraction errors by 40% when page structure shifts, as described in Bright Data's guidance on fixing inaccurate scraping data.

A reliable Puppeteer pattern

Install Puppeteer:

  • npm install puppeteer

Create dynamic-scrape.js:

import puppeteer from "puppeteer";

async function scrapeDynamicPage() {
  const browser = await puppeteer.launch({
    headless: true,
  });

  const page = await browser.newPage();

  await page.goto("https://quotes.toscrape.com/js/", {
    waitUntil: "domcontentloaded",
  });

  await page.waitForSelector(".quote");

  const quotes = await page.evaluate(() => {
    return Array.from(document.querySelectorAll(".quote")).map((quote) => {
      const text =
        quote.querySelector(".text")?.textContent?.trim() || null;
      const author =
        quote.querySelector(".author")?.textContent?.trim() || null;

      return { text, author };
    });
  });

  console.log(quotes);

  await browser.close();
}

scrapeDynamicPage().catch((error) => {
  console.error("Dynamic scrape failed:", error.message);
});

The important line isn't goto. It's waitForSelector(".quote").

A junior developer often waits for a generic load event and then wonders why the extraction is flaky. On dynamic pages, “page loaded” and “data rendered” are different states. Your scraper should wait for the specific UI evidence that the target content exists.

For teams that already think thoroughly about browser execution and reliability, this write-up on Faberwork's approach to effective browser testing is useful because it reflects the same discipline: don't trust broad load assumptions when the value depends on the rendered state.

Tooling Comparison Static vs Dynamic Scraping

Criterion Axios & Cheerio Puppeteer & Playwright
Best fit Server-rendered pages JavaScript-rendered pages
Resource use Light Heavy
Speed Fast Slower because a browser runs
Debugging style Inspect raw HTML Inspect rendered DOM and events
Failure mode Missing data in response Timing, selectors, browser state
Maintenance burden Lower on simple sites Higher on interactive sites

Browser automation solves a different class of problem. It doesn't make a scraper more advanced by itself. It makes it more expensive, slower, and sometimes necessary.

Evading Blocks and Scaling Your Scrapers

A scraper that works once isn't the same thing as a scraper that survives repeated runs. Most failures start after the extraction logic is already “done.” You add concurrency, schedule the job, point it at more pages, and then the target starts responding differently or stops responding at all.

Why first success usually fails in production

The classic mistake is firing a large burst of requests with Promise.all() and assuming JavaScript's async model equals safe parallelism. It doesn't. Firecrawl's production guidance warns that sending 100 concurrent requests is highly likely to trigger immediate blocking, and recommends batching, delays, or managed infrastructure instead of raw parallel bursts, as explained in Firecrawl's JavaScript scraping guide.

That changes how you should think about scale. The question isn't “How many requests can Node run at once?” The question is “What request pattern still looks acceptable to the target site?”

An infographic comparing the pros and cons of evading blocks and scaling web scraping operations.

API first versus DOM first

A lot of JavaScript scrapers are overbuilt because developers jump straight to DOM extraction. In practice, many modern sites fetch data from underlying JSON endpoints. Bright Data notes that directly calling those APIs can reduce request volume by 90% compared with browser-driven scraping, which is why inspecting the Network tab is often the most valuable habit before you write more automation, as discussed in Bright Data's analysis of scraping without getting blocked.

That leads to a simple decision rule:

  • Use API-first when the page already calls a clean endpoint that returns the data you need.
  • Use DOM-first when the API is unavailable, incomplete, protected in a way you can't practically handle, or the page state depends on interactions that only a browser can reproduce.

When you do need proxy support, request shaping matters as much as the proxy itself. This guide on preventing data ingestion failures is worth reading because it frames proxy use as part of reliability engineering, not as a magic bypass.

If you're operating inside the Apify ecosystem, this guide to using Apify Proxy without getting blocked is a practical reference for shaping traffic rather than just routing it.

A safer scaling pattern

Don't think in terms of maximum concurrency. Think in terms of a controlled work queue.

import axios from "axios";

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function scrapeBatch(urls) {
  const results = [];

  for (const url of urls) {
    try {
      const response = await axios.get(url, {
        headers: { "User-Agent": "Mozilla/5.0" },
      });

      results.push({ url, html: response.data });
    } catch (error) {
      results.push({ url, error: error.message });
    }

    await delay(1500);
  }

  return results;
}

This isn't flashy, but it reflects production reality better than aggressive parallelism.

A durable scraper usually adds these controls:

  • Request pacing so the traffic doesn't arrive as a suspicious burst
  • Retry logic for transient failures, not endless loops
  • Proxy rotation when the target is sensitive to repeated requests from one network
  • Session awareness when a site ties state to cookies or flows
  • API auditing before browser automation to avoid expensive rendering work

Structuring and Storing Your Scraped Data

Getting the HTML is only the start. Most scraped data is messy on arrival. Text contains extra whitespace, prices are strings, labels vary across pages, and optional fields appear only on some records. If you skip cleanup, the scraper technically works while the downstream data remains annoying to use.

Clean data beats more data

The handoff you want is simple: extraction code produces a predictable object shape, even when some source fields are missing or messy.

That means normalizing at the boundary:

  • Trim text so comparisons and storage don't preserve random spaces
  • Convert types where possible, especially prices and counts
  • Handle nulls intentionally rather than leaving undefined values scattered around
  • Store metadata such as timestamps or source URLs with each record

A five-step flowchart illustrating the process of collecting, cleaning, transforming, storing, and utilizing web scraped data.

A before and after example

Raw output often looks like this:

{
  title: "  Wireless Mouse \n",
  price: " $29.99 ",
  availability: "",
  rating: undefined
}

That object is awkward to search, sort, and trust. Clean it before storage:

function normalizeProduct(raw) {
  return {
    title: raw.title?.trim() || null,
    priceText: raw.price?.trim() || null,
    availability: raw.availability?.trim() || "unknown",
    rating: raw.rating || null,
    scrapedAt: new Date().toISOString(),
  };
}

Result:

{
  title: "Wireless Mouse",
  priceText: "$29.99",
  availability: "unknown",
  rating: null,
  scrapedAt: "2026-01-01T12:00:00.000Z"
}

The scraper's job isn't finished when it finds the element. It's finished when the record is usable.

Choosing where the data should live

For small projects, flat files are fine. For anything recurring, searchable, or shared, move to a database earlier than you think.

Storage option Good for Trade-off
JSON file Prototyping, local inspection Awkward for repeated updates
CSV Spreadsheet workflows, simple exports Weak for nested fields
SQLite Local apps, moderate datasets More setup than files
Cloud database Shared products and pipelines Operational overhead

A practical pattern for early-stage scrapers is:

  1. Store raw captures if debugging matters.
  2. Store cleaned records separately.
  3. Append metadata like scrape time and page URL.
  4. Validate shape before write so one broken page doesn't poison the dataset.

That split between raw and cleaned data saves hours when a site changes and you need to inspect what came back.

Deploying and Monetizing Your Scraper as an Apify Actor

A local scraper is useful for learning. It's unreliable as a product. Your laptop sleeps, your network changes, credentials drift, and manual execution doesn't scale. If the scraper matters, it needs a proper runtime with logs, inputs, scheduling, and outputs you can call from somewhere else.

Why local scripts stop being enough

Cloud deployment matters even for small projects because it changes how you design the scraper. Once it runs outside your machine, you start caring about predictable inputs, recoverable failures, structured outputs, and re-runs.

Screenshot from https://apifyhub.com

There's also a practical reason to package browser-based scrapers carefully. As noted earlier from Bright Data's reporting, headless browsers can reach 96.5% success rate with residential proxies and full JavaScript rendering, but that result depends on proper waiting logic and selector fallbacks that reduce data errors by 40% when structures change. That's one more reason to formalize the scraper instead of leaving it as a one-off script.

From scraper to Actor

An Apify Actor gives your scraper a deployable shape. You define the input, run the logic in a managed environment, and persist output in a way other systems can consume. The practical shift is from “script file” to “service unit.”

A solid first Actor usually has:

  • A clear input schema such as start URLs, max pages, and extraction mode
  • One output format that downstream users can rely on
  • Logging around decisions like skipped pages, retries, and selector failures
  • Configurable execution paths so the same Actor can run static or browser-based logic depending on the target

If you're building your first one, this guide to creating a first JavaScript Actor is the right level of detail because it focuses on packaging and deployment rather than only scraping syntax.

A short walkthrough helps here:

Finding a market before you build blind

Deployment is only half the upgrade. The other half is deciding whether your scraper solves a problem other people will pay for. That's where marketplace research matters.

Some scrapers are technically impressive and commercially weak. Others solve a narrow, repetitive task and become useful products because they save buyers time immediately. Before publishing an Actor, inspect what already exists in the store, how crowded the niche is, what pricing models are common, and whether the demand seems concentrated around a category or a keyword set.

That's where Apify Hub fits as a research layer over public Apify store data. It lets you review actor usage, categories, keyword patterns, pricing signals, and competition depth before you commit to building or listing in a crowded niche.


If you're turning web scraping with JavaScript into a product instead of a side script, Apify Hub is a practical place to assess demand, compare existing Actors, and spot niches worth building for before you spend time on the wrong scraper.