ego (lite) is just a browser, ego is your personal agent across devices.
Join waitlist
JavaScriptPythonWeb scrapingPlaywright

JavaScript vs Python for web scraping: compare workflow and verified output

Oct 13, 202612 min read
Illustration of JavaScript and Python in a balanced web scraping comparison

A scraper that returns the wrong twenty records is still wrong in two languages. Rewriting it from Python into JavaScript will not repair a missing page or an author attached to the wrong quote. Choose the language after defining the output you will accept.

JavaScript vs Python for web scraping is a decision about the runtime, libraries and development workflow used to collect and validate web data. Both languages can operate a browser. The useful comparison holds the target, browser behavior and result contract constant, then asks which implementation your team can maintain.

The examples below use Playwright in both languages against the same public training site. A recorded October 13, 2026 run collected matching output in JavaScript, Python and an independent ego (lite) browser workflow. The code listings are illustrative implementations; the screenshots and saved run artifacts document the executed versions.

Which language should your team choose?

Choose the language that fits the system consuming the data and the people responsible for failures. A Node.js application may benefit from a JavaScript scraper in the same toolchain; a Python data pipeline may benefit from keeping collection and analysis together.

DecisionJavaScript / Node.jsPython
Existing applicationNatural fit for a Node.js serviceNatural fit for a Python service or data job
Browser automationPlaywright library with async APIsPlaywright with synchronous or asynchronous APIs
Page-side logicJavaScript in the browser and hostPython in the host; JavaScript can still run in the page
Data processingReuse your JavaScript validation and storage codeReuse your Python validation and analysis code
MaintenanceFavor the language the responsible team debugs confidentlyApply the same ownership test

There is little value in a language switch that leaves no one able to diagnose the next incident. Consider packaging, scheduled execution, logging and downstream schema checks alongside the extraction function. Those costs persist after the demo works.

If the choice is open, implement one small task in both. Keep the first versions and note where setup, editing or debugging became difficult. A short, controlled task can expose a workflow preference without pretending to benchmark the whole ecosystem.

Does a JavaScript page require a JavaScript scraper?

No. The browser executes the page's JavaScript regardless of which supported host language controls it. Python Playwright can wait for rendered content and interact with it just as the Node.js library can.

The official Node.js library guide explains its asynchronous browser API. The Python library guide documents both sync and async variants, including headed execution. In Python, evaluating a JavaScript function in the page is a browser operation, not a conversion of the scraper into a Node.js program.

Transport is a separate choice. If the required fields are in the initial HTML, an HTTP client and parser may be enough. If the task requires rendered state or UI interaction, a browser route may be appropriate. Compare two HTTP implementations or two browser implementations when trying to isolate the language decision.

Comparing a Python HTTP request with a JavaScript browser launch changes rendering, resource use and readiness behavior along with the language. That can still answer an architecture question, but it cannot attribute the difference to Python versus JavaScript alone.

What is the shared task and result contract?

The task is to read the first two pages of Quotes to Scrape and produce the same four fields for each quote. The fixture is small enough to inspect manually and includes pagination, repeated cards and multi-value tags.

Use the first page and page two. Before a run, confirm that each still has ten quote cards. The sample expects twenty unique text-and-author pairs across these pages; a changed fixture should fail that expectation visibly.

ContractRule for both implementations
URLsThe same two explicit URLs, in the same order
BrowserChromium, headed, matching Playwright release and browser build
ContextFresh context; same viewport; no imported login state
ReadinessDOM loaded, then a quote card visible
Work patternSequential pages; no application retries
Fieldstext, author, tags, source_url
NormalizationCollapse whitespace; preserve punctuation and tag order
AcceptanceTen records per page, twenty unique pairs, required fields nonempty
EvidenceRaw extracted fields, normalized JSON and failure details

These structural checks are only the first pass. An independent source review still needs to confirm that each author and tag belongs to the correct quote. Matching JSON outputs could mean both scripts share the same extraction mistake.

How do you implement the task in JavaScript?

Use the Playwright library for this standalone Node.js script. Save it as quotes.mjs, install a chosen Playwright release and its Chromium browser, and record both versions. Choose the same release for the Python run rather than independently installing whatever each environment considers latest.

This example captures raw fields first, then normalizes and checks them. It stops on an unexpected page response or result count instead of writing an empty success result. The selectors are specific to this fixture and must be inspected again if its DOM changes.

import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { chromium } from 'playwright';

const urls = [
  'https://quotes.toscrape.com/',
  'https://quotes.toscrape.com/page/2/',
];
const clean = value => value.replace(/\s+/gu, ' ').trim();
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({
  viewport: { width: 1280, height: 800 },
});
const page = await context.newPage();
const raw = [];

try {
  for (const url of urls) {
    const response = await page.goto(url, {
      waitUntil: 'domcontentloaded',
    });
    assert(response?.ok(), `Page failed: ${url}`);
    await page.locator('.quote').first().waitFor({ state: 'visible' });
    const records = await page.locator('.quote').evaluateAll(cards =>
      cards.map(card => ({
        text: card.querySelector('.text')?.textContent ?? '',
        author: card.querySelector('.author')?.textContent ?? '',
        tags: [...card.querySelectorAll('.tags .tag')]
          .map(tag => tag.textContent ?? ''),
        source_url: location.href,
      })),
    );
    raw.push(...records);
    assert.equal(records.length, 10, `Count changed: ${url}`);
  }
  const rows = raw.map(row => ({
    ...row,
    text: clean(row.text),
    author: clean(row.author),
    tags: row.tags.map(clean),
  }));
  assert.equal(rows.length, 20);
  assert(rows.every(row => row.text && row.author
    && urls.includes(row.source_url)
    && row.tags.length > 0 && row.tags.every(Boolean)));
  const keys = rows.map(row => JSON.stringify([row.text, row.author]));
  assert.equal(new Set(keys).size, 20);
  await writeFile('quotes-js.json', JSON.stringify(rows, null, 2));
} finally {
  await writeFile('quotes-js-raw.json', JSON.stringify(raw, null, 2));
  await context.close();
  await browser.close();
}

Run it with node quotes.mjs. A failed assertion exits with an error, while the finally block retains any raw records collected before failure. Keep the terminal error with those records; the raw file alone does not say whether the task passed.

The browser closes when the script finishes. For material capture, pause in a debugger before cleanup or add an explicit review pause to both versions. Exclude that pause from execution timing. Making only one run wait for a screenshot would distort a speed comparison.

In the supplied JavaScript run, Node.js 24.19.0 and Playwright 1.62.1 launched headed Chromium with a fresh 1280 × 800 context. The first preserved script collected ten cards per page and passed the required-field, exact-URL and unique-pair checks with zero application retries.

JavaScript extraction prompt and execution trace beside page two in headed Chromium
The JavaScript run reads the long page-two quote in a visible browser. Its author, complete text and ordered tags are the fields to preserve.

Open the full-resolution capture

JavaScript result viewer showing twenty quotes, authors, ordered tags and source URLs
Page-only capture of the saved JavaScript output from the same run. This generated viewer displays results; it is not the source website.

Open the full-resolution capture

How do you implement the same task in Python?

Use Python's async Playwright API so the example follows the same sequential await pattern. Save it as quotes.py, install the matching Playwright release in a virtual environment and install its Chromium browser. Keep the Python version and dependency lock with the run.

Playwright for Python documentation showing library installation and usage
Official Python library documentation captured October 13, 2026. The version badge is documentation context, not the package used in the recorded run.

Open the full-resolution capture

The extraction function still runs JavaScript inside the page, where the DOM exists. Host-side normalization and output writing happen in Python. This separates page access from the language used to organize the pipeline.

import asyncio
import json
import re
from pathlib import Path
from playwright.async_api import async_playwright

URLS = [
    "https://quotes.toscrape.com/",
    "https://quotes.toscrape.com/page/2/",
]

def clean(value):
    return re.sub(r"\s+", " ", value).strip()

def require(condition, message):
    if not condition:
        raise ValueError(message)

async def main():
    raw = []
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        context = await browser.new_context(
            viewport={"width": 1280, "height": 800}
        )
        page = await context.new_page()
        try:
            for url in URLS:
                response = await page.goto(url, wait_until="domcontentloaded")
                require(response is not None and response.ok, f"Page failed: {url}")
                await page.locator(".quote").first.wait_for(state="visible")
                records = await page.locator(".quote").evaluate_all("""
                  cards => cards.map(card => ({
                    text: card.querySelector('.text')?.textContent ?? '',
                    author: card.querySelector('.author')?.textContent ?? '',
                    tags: [...card.querySelectorAll('.tags .tag')]
                      .map(tag => tag.textContent ?? ''),
                    source_url: location.href
                  }))
                """)
                raw.extend(records)
                require(len(records) == 10, f"Count changed: {url}")
            rows = [{**row, "text": clean(row["text"]),
                     "author": clean(row["author"]),
                     "tags": [clean(tag) for tag in row["tags"]]}
                    for row in raw]
            require(len(rows) == 20, "Total count changed")
            require(all(row["text"] and row["author"]
                        and row["source_url"] in URLS
                        and row["tags"] and all(row["tags"])
                        for row in rows), "Missing or invalid fields")
            keys = {(row["text"], row["author"]) for row in rows}
            require(len(keys) == 20, "Duplicate records")
            Path("quotes-python.json").write_text(
                json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8"
            )
        finally:
            Path("quotes-python-raw.json").write_text(
                json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8"
            )
            await context.close()
            await browser.close()

asyncio.run(main())

Run it with python quotes.py in the chosen environment. Explicit require checks keep validation active even if Python runs with optimization enabled. Like the JavaScript script, this saves partial raw extraction and closes browser resources after an error.

The samples do not add concurrency or automatic retries. Add those only after the basic result is accepted, with equivalent limits and clear failure reporting in both implementations.

The recorded Python run used Python 3.14.7 and Playwright 1.62.0. Version 1.62.1 was unavailable on the configured package index, so the run selected 1.62.0 and explicitly used the same Chromium 151.0.7922.34 executable as JavaScript. That package difference is part of the comparison. The saved Python output passed the same checks with zero application retries.

Python Playwright setup and extraction trace beside the same page-two quote
The Python path uses the same source and Chromium build. Its setup trace records the unavailable matching package release and the selected alternative.

Open the full-resolution capture

Python result viewer showing twenty quotes and the recorded package version difference
Page-only capture of the independently saved Python result. The report preserves the package-version difference rather than presenting the environments as identical.

Open the full-resolution capture

How do you verify that the outputs agree?

Compare parsed records by identity and field value, not the bytes of two JSON files. Formatting, escaping and object-key order can differ without changing data. Missing records or different author associations are substantive disagreements.

Build the same key from normalized text and author in each output. Compare the key sets first, then compare tags and source URLs for each matching key. Preserve the original tag order for this contract; sorting it would conceal an ordering difference.

Reopen the source for disagreements. Check whether the DOM changed, a redirect occurred, one script read too early or normalization removed meaningful content. Also check a sample of agreements against the source. Agreement between implementations is useful evidence, but it is not independent ground truth. The Octocrawl documentation offers another reference for keeping extracted fields checkable against their source.

Both samples collapse whitespace. JavaScript and Python have language-specific Unicode whitespace rules, so test unusual characters if that matters to your target. Define a narrower normalization rule for production rather than silently discarding differences.

The three saved outputs contain the same twenty text-and-author identities and exact values for text, author, tags and source_url. Each source contributes ten records, and tag order matches. We checked the supplied JSON files directly; the run reports also record source review. This demonstrates agreement for this task, while source checking remains necessary to catch a shared mistake.

How should you compare development effort and runtime?

Measure setup, authoring, debugging, extraction and validation separately. A completed result needs all of them. A first browser launch can include installation or cache work that is absent from later runs.

MeasurementWhat to recordWhat would distort it
SetupEnvironment, packages and browser installationCounting setup for only one language
DevelopmentFirst draft, edits and time to accepted outputComparing a rehearsed script with a new one
ExecutionNavigation through extraction, same settingsDifferent browser builds, waits or concurrency
VerificationRecord checks, source review and disagreementsStopping at JSON creation in one run
RecoveryErrors, retries and human interventionRemoving failed attempts from the comparison

Use the same machine, browser build, URL order and workload size. Run several repetitions, alternate the order and record caching or network variation. Separate a visible capture run from a timing run if review pauses are needed.

A twenty-record task can reveal a broken selector or awkward local setup. It cannot support a universal claim about language performance. Increase the workload only when it represents the actual production job, and keep validation inside the measured task boundary.

For these single recorded attempts, JavaScript reports 2.895 seconds and Python 3.423 seconds from browser launch through extraction, writes and validation. Setup and editing were not precisely timed. The ego (lite) report records 4.162 seconds of active collection, reload verification and saving, plus a separate 1.617-second initial browser round; it excludes agent reasoning and later comparison. These different measurement boundaries and one attempt per path do not establish a language or tool speed advantage.

Where does ego (lite) fit in this workflow?

A visible ego (lite) browser workspace can support an agent-assisted source review alongside either implementation. The coding language does not determine whether a reviewer needs to inspect the page and the extracted records together.

In the supplied run, the connected coding agent opened both exact URLs in the named Language comparison: ego Space, collected its own twenty records and saved them before reading the JavaScript and Python outputs. It reloaded both sources to verify the saved fields. The ego-browser documentation describes how agents use that browser environment. Keep source inspection separate from edits to either scraper so the review cannot quietly repair its evidence.

The visible Space makes the live source, task controls and extraction process easy to inspect together. One missing guide-path setup error was resolved using the installed helper; the report records zero collection errors, recovery retries or human interventions. This agent-assisted browser workflow complements the language implementations when a reviewer needs to check field meaning against the current page.

Codex prompt and extraction trace beside the live Language comparison ego Space with agent controls
The same-run controller and Space frame shows source inspection with Agent is in control, Take over and Stop visible. The independent output was saved before reading the other two outputs.

Open the full-resolution capture

View the complete twenty-record result capture
Same-run ego result viewer showing the three-output agreement and complete saved records
Page-only capture of the same-run result viewer. All twenty identities and four fields match the JavaScript and Python outputs, including tag order. This is output evidence, not a runtime benchmark.

Open the full-resolution capture

FAQ

These answers focus on the language choice. Rendering, access and acceptance requirements should be established before interpreting differences between implementations.

Is Python better than JavaScript for web scraping?

It can be a better fit for a Python data pipeline, but there is no universal winner. Compare maintenance, integration and accepted output for the workload your team will actually operate.

Can Python scrape JavaScript-rendered websites?

Yes, through a browser tool such as Playwright. The browser executes page scripts; Python controls navigation, waits and interactions through its supported API.

Is JavaScript faster for browser scraping?

That needs a controlled measurement. Page rendering, network behavior, browser settings and validation may dominate the task. A result from different frameworks or transports cannot isolate language performance.

Why use Playwright in both examples?

It reduces differences in the browser-control layer while comparing the host-language workflows. Matching releases and browser builds still needs to be recorded; sharing a library name alone is insufficient.

Can both examples use a headed browser?

Yes. The examples set headless: false in JavaScript and headless=False in Python. Add the same review pause to both if you need final-page screenshots.

Why does the Python script contain JavaScript?

Its evaluation callback runs in the browser against the DOM. Python remains the host language controlling the task and writing output. Page-side and host-side code have different roles.

Do I need a browser for static HTML?

Not always. If the required data is available through an authorized HTTP response, a client and parser may be sufficient. Choose that route before comparing the language implementations.

Does matching JSON prove both scrapers are correct?

No. They could share a selector error or normalization mistake. Check key sets and fields, then independently compare records with the source and report gaps.

Should I add concurrency immediately?

Start with an accepted sequential result. Then add bounded concurrency appropriate to the target and measure failures, resource use and throughput under the same conditions in both languages.

When should I keep my existing language?

When it already meets the task contract and the team can maintain it. Prefer a concrete integration or maintenance benefit over a rewrite based on an unsupported speed claim.

Continue with a language-specific implementation, a broader extraction-tool decision or the evidence needed to trust an agent's result.