惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

小众软件
小众软件
IT之家
IT之家
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Security Archives - TechRepublic
Security Archives - TechRepublic
P
Proofpoint News Feed
C
CERT Recently Published Vulnerability Notes
阮一峰的网络日志
阮一峰的网络日志
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The Cloudflare Blog
P
Palo Alto Networks Blog
Know Your Adversary
Know Your Adversary
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Cisco Talos Blog
Cisco Talos Blog
L
Lohrmann on Cybersecurity
AWS News Blog
AWS News Blog
J
Java Code Geeks
博客园_首页
Scott Helme
Scott Helme
WordPress大学
WordPress大学
有赞技术团队
有赞技术团队
T
The Exploit Database - CXSecurity.com
Security Latest
Security Latest
V
Visual Studio Blog
Cloudbric
Cloudbric
Jina AI
Jina AI
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
博客园 - 叶小钗
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 聂微东
人人都是产品经理
人人都是产品经理
A
Arctic Wolf
C
Cybersecurity and Infrastructure Security Agency CISA
S
SegmentFault 最新的问题
The Last Watchdog
The Last Watchdog
SecWiki News
SecWiki News
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
W
WeLiveSecurity
K
Kaspersky official blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Hacker News: Ask HN
Hacker News: Ask HN
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
宝玉的分享
宝玉的分享
Hugging Face - Blog
Hugging Face - Blog
量子位
Google Online Security Blog
Google Online Security Blog
博客园 - Franky
Simon Willison's Weblog
Simon Willison's Weblog
博客园 - 三生石上(FineUI控件)
Recent Commits to openclaw:main
Recent Commits to openclaw:main

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
How to tell if a page uses JavaScript rendering (and what to do about it)
John Rooney · 2026-05-11 · via DEV Community

Selector woes

You write a scraper, test your selectors in the browser, and everything looks right. Then you run the spider and get back nothing. This is the most common point of confusion for developers new to web scraping: the browser shows you the data, your scraper does not find it, and the two are looking at completely different things.

The browser executes JavaScript before you see anything on screen, but your scraper, unless you specifically configure it to do otherwise, does not. It sees the raw HTML the server sent before any JavaScript ran, and on a modern web application that raw HTML is often just a shell: a few <div> tags, some <script> elements, and no actual content.

Figuring out whether a page uses JavaScript rendering takes about two minutes and a browser you already have open, and once you know what you are dealing with, the path forward is clear.

The two-minute test

Open the page you want to scrape. Right-click anywhere on the page and select View Page Source: not Inspect, not DevTools, but View Page Source. This shows you the raw HTML the server sent before the browser ran any JavaScript.

Now search that source for a piece of text you can see on the rendered page: a product name, a price, a headline, anything specific.

If you find it, the content is in the HTML and your scraper can extract it without JavaScript rendering. If you do not find it, the content was injected by JavaScript after the page loaded, your scraper will not find it either, and you need a different approach.

That is the entire test. Everything else in this guide is detail.

Understanding why this happens

It helps to understand the three different ways a page can deliver its content, because each one requires a different scraping approach.

Server-side rendering. The server builds the complete HTML page and sends it. When the browser receives it, all the content is already in the markup. Wikipedia works this way, many news sites work this way, and older e-commerce platforms work this way. This is the easiest case for scraping: requests and Beautiful Soup are sufficient.

Client-side rendering. The server sends a minimal HTML shell with almost no content, plus a JavaScript bundle. The JavaScript runs in the browser, fetches data from an API, and builds the DOM dynamically, which means the content never exists in the original HTML. React, Vue, and Angular applications built as single-page applications typically work this way, and they require either browser rendering or finding and calling the underlying API directly.

Hybrid rendering. The server sends an HTML page with some content already in it (enough for search engines and initial paint), and JavaScript then enhances the page by adding more data, enabling interactivity, and loading supplementary content. Many modern e-commerce and content sites work this way, and depending on which data you need, you may or may not need JavaScript rendering.

The View Source test tells you which case you are in.

Confirming it with Python

The manual test is fast, but if you want to confirm programmatically or build a check into your scraping toolchain, a comparison of the raw response against the rendered DOM is definitive.

import requests
from bs4 import BeautifulSoup

def check_for_js_rendering(url: str, search_text: str) -> dict:
    """
    Fetch a page with plain requests and check whether expected text is present.
    Returns a diagnostic dict.
    """
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    }

    response = requests.get(url, headers=headers, timeout=15)

    result = {
        "url": url,
        "status_code": response.status_code,
        "content_length": len(response.text),
        "search_text": search_text,
        "found_in_raw_html": search_text.lower() in response.text.lower(),
        "likely_js_rendered": False,
        "notes": [],
    }

    soup = BeautifulSoup(response.text, "lxml")

    # Check for signals that suggest client-side rendering
    script_tags = soup.find_all("script")
    result["script_count"] = len(script_tags)

    # Common JS framework root elements
    js_roots = soup.select("#root, #app, #__next, #__nuxt, [data-reactroot]")
    if js_roots:
        result["notes"].append(f"Found JS framework root element: {js_roots[0]}")
        result["likely_js_rendered"] = True

    # Very little visible text relative to page size is a strong signal
    visible_text = soup.get_text(strip=True)
    text_ratio = len(visible_text) / max(len(response.text), 1)
    result["text_ratio"] = round(text_ratio, 3)
    if text_ratio < 0.05 and len(response.text) > 5000:
        result["notes"].append(f"Low text ratio ({text_ratio:.1%}) suggests JS-rendered content")
        result["likely_js_rendered"] = True

    # Explicit not-found confirmation
    if not result["found_in_raw_html"]:
        result["likely_js_rendered"] = True
        result["notes"].append(f"'{search_text}' not found in raw HTML — almost certainly JS rendered")

    return result


# Usage
result = check_for_js_rendering(
    url="https://example.com/products/headphones",
    search_text="Wireless Headphones"
)

print(f"Status: {result['status_code']}")
print(f"Content length: {result['content_length']} bytes")
print(f"Text ratio: {result['text_ratio']:.1%}")
print(f"Found in raw HTML: {result['found_in_raw_html']}")
print(f"Likely JS rendered: {result['likely_js_rendered']}")
for note in result["notes"]:
    print(f"  -> {note}")

Enter fullscreen mode Exit fullscreen mode

Reading the signals

Even before searching for specific text, certain patterns in the raw HTML tell you what you are dealing with.

Signal one: the HTML is nearly empty

Open View Source and immediately scroll down. A server-rendered page will have recognizable HTML structure: <header>, <main>, product listings, article text, navigation. A client-side rendered page will often look something like this:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>My Store</title>
    <link rel="stylesheet" href="/static/css/main.abc123.css">
  </head>
  <body>
    <div id="root"></div>
    <script src="/static/js/main.def456.js"></script>
  </body>
</html>

Enter fullscreen mode Exit fullscreen mode

A <div id="root"> or <div id="app"> with nothing inside it is the unmistakable signature of a React or Vue single-page application. Everything visible in the browser was injected by JavaScript into that empty div.

from bs4 import BeautifulSoup
import requests

def has_empty_app_root(url: str) -> bool:
    response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"})
    soup = BeautifulSoup(response.text, "lxml")

    # These are the standard mounting points for major JS frameworks
    for selector in ("#root", "#app", "#__next", "#__nuxt", "[data-reactroot]"):
        el = soup.select_one(selector)
        if el and not el.get_text(strip=True):
            print(f"Found empty JS root: {selector}")
            return True

    return False

Enter fullscreen mode Exit fullscreen mode

Signal two: JavaScript framework fingerprints

Even on hybrid-rendered pages, certain markers identify which JavaScript framework is in use:

def identify_js_framework(html: str) -> list[str]:
    """Identify JavaScript frameworks present on the page."""
    frameworks = []

    checks = [
        ("React",         ["data-reactroot", "data-reactid", "__REACT_DEVTOOLS"]),
        ("Next.js",       ["__NEXT_DATA__", "_next/static", "__next"]),
        ("Vue",           ["data-v-", "__vue__", "nuxt__", "__NUXT__"]),
        ("Angular",       ["ng-version", "_nghost", "ng-app"]),
        ("Nuxt",          ["__NUXT__", "__nuxt"]),
        ("Gatsby",        ["___gatsby", "__PATH_PREFIX__"]),
        ("Svelte",        ["__svelte", "svelte-"]),
    ]

    for framework, markers in checks:
        if any(marker in html for marker in markers):
            frameworks.append(framework)

    return frameworks


response = requests.get("https://example.com", headers={"User-Agent": "Mozilla/5.0"})
frameworks = identify_js_framework(response.text)
if frameworks:
    print(f"JS frameworks detected: {', '.join(frameworks)}")
    print("Page likely requires JavaScript rendering for full content")
else:
    print("No major JS framework detected — likely server-rendered")

Enter fullscreen mode Exit fullscreen mode

Signal three: the Network tab in DevTools

For any page where you are unsure, the Network tab in browser DevTools gives you a definitive picture. Open DevTools, click the Network tab, and reload the page.

Look at the first request: the one for the HTML document itself. Click it and look at the Response tab. If the response body contains the data you want, you do not need JavaScript rendering. If it contains an empty shell, you do.

While you are there, also filter to Fetch/XHR and check whether the data you want is arriving via an API call in the background, such as a request to /api/products returning JSON. If it is, you may not need browser rendering at all, because you can call that API directly from Python, which is faster and more reliable than rendering the full page. The guide on intercepting XHR and fetch requests covers this in detail.

Signal four: the page works without CSS but breaks without JavaScript

A quick test that sometimes reveals the answer is to open your browser's developer settings, disable JavaScript, and reload the page. If the content disappears or the page shows a "Please enable JavaScript" message, the content is JavaScript-rendered.

What to do about it

Once you have confirmed the page requires JavaScript rendering, you have three paths forward. They are not mutually exclusive: the right choice depends on what the page is doing, how much data you need, and how much complexity you want to manage.

Path one: find and call the API directly

This is always the first thing to try, because when it works it is the best outcome. Many JavaScript-rendered pages load their data from a JSON API, and if you can find that API, you can call it directly from Python and get clean, structured data without running a browser at all.

Open the Network tab in DevTools, filter to Fetch/XHR, reload the page, and look for requests returning JSON that contains the data you want. Right-click any promising request and select Copy, then Copy as cURL.

import requests

# Reproduced from the cURL command copied from DevTools
response = requests.get(
    "https://example.com/api/v2/products",
    params={"category": "electronics", "page": 1},
    headers={
        "Accept": "application/json",
        "Referer": "https://example.com/products",
        "User-Agent": "Mozilla/5.0",
    },
)

data = response.json()
products = data["products"]
print(f"Found {len(products)} products via API")

Enter fullscreen mode Exit fullscreen mode

If the API requires authentication tokens that are generated by JavaScript on page load, you can extract them first and then call the API directly:

from playwright.sync_api import sync_playwright
import requests

def get_api_token(url: str) -> str | None:
    """Extract an API token from a JS-rendered page."""
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url)
        page.wait_for_load_state("networkidle")

        # Try common locations for auth tokens
        token = (
            page.evaluate("() => window.__INITIAL_STATE__?.auth?.token") or
            page.evaluate("() => localStorage.getItem('token')") or
            page.get_attribute('meta[name="api-token"]', "content")
        )
        browser.close()
        return token

token = get_api_token("https://example.com/products")
if token:
    response = requests.get(
        "https://example.com/api/products",
        headers={"Authorization": f"Bearer {token}"},
    )
    products = response.json()

Enter fullscreen mode Exit fullscreen mode

Path two: use Playwright or Selenium to render the page

When the API approach is not viable (because the data is not available via a clean API, or the authentication is too complex to reproduce), use a browser automation library to render the page and scrape the resulting DOM.

Playwright is the recommended choice for new projects, since it is faster than Selenium, has a cleaner async API, and supports Chromium, Firefox, and WebKit.

from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup

def scrape_with_playwright(url: str) -> list[dict]:
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()

        # Block images and fonts to speed up page loads
        page.route(
            "**/*.{png,jpg,jpeg,gif,webp,svg,woff,woff2,ttf,eot}",
            lambda route: route.abort()
        )

        page.goto(url)

        # Wait for the content you actually need, not just page load
        page.wait_for_selector("article.product", timeout=10000)

        # Parse with Beautiful Soup — the HTML is now fully rendered
        html = page.content()
        browser.close()

    soup = BeautifulSoup(html, "lxml")
    products = []
    for article in soup.select("article.product"):
        products.append({
            "name": article.select_one(".product-title").get_text(strip=True)
                    if article.select_one(".product-title") else None,
            "price": article.select_one(".price").get_text(strip=True)
                     if article.select_one(".price") else None,
        })

    return products

Enter fullscreen mode Exit fullscreen mode

For async crawls:

import asyncio
from playwright.async_api import async_playwright
from bs4 import BeautifulSoup

async def scrape_page(browser, url: str) -> list[dict]:
    page = await browser.new_page()
    try:
        await page.route(
            "**/*.{png,jpg,jpeg,gif,webp}",
            lambda route: route.abort()
        )
        await page.goto(url)
        await page.wait_for_selector("article.product")
        html = await page.content()
        soup = BeautifulSoup(html, "lxml")
        return [
            {
                "name": a.select_one(".product-title").get_text(strip=True)
                        if a.select_one(".product-title") else None,
            }
            for a in soup.select("article.product")
        ]
    finally:
        await page.close()

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        urls = [
            "https://example.com/products?page=1",
            "https://example.com/products?page=2",
        ]
        tasks = [scrape_page(browser, url) for url in urls]
        results = await asyncio.gather(*tasks)
        await browser.close()
        return [item for page_items in results for item in page_items]

products = asyncio.run(main())

Enter fullscreen mode Exit fullscreen mode

Choosing what to wait for

The most common mistake with Playwright is waiting for the wrong thing. page.wait_for_load_state("load") fires when the initial HTML and scripts have loaded, not when the JavaScript has finished rendering content. Use one of these instead:

# Wait for a specific element you need to be present
page.wait_for_selector(".product-listing")

# Wait for network activity to stop (risky — some pages have background polling)
page.wait_for_load_state("networkidle")

# Wait for a specific API response to complete
with page.expect_response("**/api/products**") as resp_info:
    page.goto(url)
response = resp_info.value
data = response.json()  # intercept the API response directly

# Wait for an element to contain specific text
page.wait_for_function("document.querySelector('.price')?.textContent?.includes('£')")

Enter fullscreen mode Exit fullscreen mode

Path three: use Zyte API

If you are running scrapers at scale, managing a fleet of browser instances is a significant operational burden, since each browser process uses substantial memory (300–500 MB at minimum), headless browsers require careful configuration to avoid detection, and the infrastructure to run them reliably across many concurrent jobs requires real engineering investment.

Zyte API handles browser rendering and bot detection at the infrastructure level. You send a standard HTTP request and get back rendered HTML, with the browser execution, proxy rotation, and fingerprint management handled by the platform.

import requests

response = requests.post(
    "https://api.zyte.com/v1/extract",
    auth=("YOUR_API_KEY", ""),
    json={
        "url": "https://example.com/products",
        "browserHtml": True,   # request browser-rendered HTML
    },
)

data = response.json()
rendered_html = data["browserHtml"]

# Parse with Beautiful Soup as normal
from bs4 import BeautifulSoup
soup = BeautifulSoup(rendered_html, "lxml")
products = soup.select("article.product")
print(f"Found {len(products)} products")

Enter fullscreen mode Exit fullscreen mode

In Scrapy, Zyte API integrates via the scrapy-zyte-api package:

# settings.py
ZYTE_API_KEY = "YOUR_API_KEY"
DOWNLOAD_HANDLERS = {
    "http":  "scrapy_zyte_api.ScrapyZyteAPIDownloadHandler",
    "https": "scrapy_zyte_api.ScrapyZyteAPIDownloadHandler",
}

# Spider: request browser-rendered HTML for specific requests
def start_requests(self):
    yield scrapy.Request(
        "https://example.com/products",
        meta={
            "zyte_api_automap": True,
            "zyte_api": {"browserHtml": True},
        },
    )

Enter fullscreen mode Exit fullscreen mode

How to decide

Is the data in the raw HTML? Run the View Source test. If yes, use requests and Beautiful Soup or Scrapy and skip browser rendering entirely.

Is the data loaded via a visible API call? Check the Network tab with the Fetch/XHR filter. If yes, call the API directly from Python, since it will be faster and more reliable than any rendering approach.

Is the data accessible via the API but protected by a token that requires JavaScript to generate? Extract the token with a single Playwright page load, then call the API directly for the actual data scraping: one browser load, many API calls.

Do you need to interact with the page (scroll, click, fill forms, navigate tabs) to reveal the data? Use Playwright or Selenium.

Are you running this at scale and do not want to manage browser infrastructure? Use Zyte API.

When rendering is slower than you expect

Browser rendering is ten to twenty times slower than a plain HTTP request and uses significantly more memory, but a few practical adjustments make a meaningful difference.

Block unnecessary resources. Images, fonts, video, and tracking scripts add load time without contributing to the data you need:

page.route(
    "**/*.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf,mp4,webm}",
    lambda route: route.abort()
)

# Also block common tracking and analytics domains
def block_trackers(route):
    blocked = ["google-analytics.com", "googletagmanager.com",
               "facebook.net", "doubleclick.net", "hotjar.com"]
    if any(domain in route.request.url for domain in blocked):
        route.abort()
    else:
        route.continue_()

page.route("**/*", block_trackers)

Enter fullscreen mode Exit fullscreen mode

Reuse browser contexts rather than browser instances. Creating a new browser is expensive; creating a new context within an existing browser is cheap:

async with async_playwright() as p:
    browser = await p.chromium.launch(headless=True)

    async def scrape(url):
        # New context per task — isolated cookies and storage, reuses browser process
        context = await browser.new_context()
        page = await context.new_page()
        await page.goto(url)
        html = await page.content()
        await context.close()
        return html

    results = await asyncio.gather(*[scrape(url) for url in urls])
    await browser.close()

Enter fullscreen mode Exit fullscreen mode

Wait for specific elements rather than network idle. networkidle waits until there are no active network connections for 500 ms, and many pages never reach this state because they have background analytics pings. Waiting for the specific element you need is faster and more reliable.

Checking your work

Once you have set up rendering, confirm it is actually returning the content you need:

from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup

def verify_rendering(url: str, expected_text: str) -> bool:
    """Confirm that rendering produces the expected content."""
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url)
        page.wait_for_load_state("networkidle")
        html = page.content()
        browser.close()

    soup = BeautifulSoup(html, "lxml")
    found = expected_text.lower() in soup.get_text().lower()
    print(f"'{expected_text}': {'found ✓' if found else 'not found ✗'}")
    return found

verify_rendering("https://example.com/products/headphones", "Wireless Headphones")

Enter fullscreen mode Exit fullscreen mode

Next steps

Once you know a page requires JavaScript rendering and have chosen your approach, the next challenge is often what happens to the data after you extract it, since cleaning, deduplication, and storage work the same regardless of how the HTML was obtained. If you went the API interception route, the guide on intercepting XHR and fetch requests in the browser covers the full workflow from DevTools discovery to paginated API calls in Python. If you are running Playwright at scale inside Scrapy, web scraping dynamic websites with Zyte API covers how to handle rendering and unblocking without managing browser infrastructure yourself.