#ExtractSummit2026 The world's largest web scraping conference returns. Austin Oct 7–8 · Dublin Nov 10–11.

Register now
Data Services
Pricing
Login
Try Zyte APIContact Sales
  • Unblocking and Extraction

    Zyte API

    The ultimate API for web scraping. Avoid website bans and access a headless browser or AI Parsing

    Ban Handling

    Headless Browser

    AI Extraction

    SERP

    Enterprise

    DocumentationSupport

    Hosting and Deployment

    Scrapy Cloud

    Run, monitor, and control your Scrapy spiders however you want to.

    Coding Agent Add-Ons

    Agentic Web Data

    Plugins that give coding agents the context to build production Scrapy projects. Starts with Claude Code.

  • Data Services
  • Pricing
  • Browse

    • BlogArticles, podcasts, videos
    • Case studiesCustomer outcomes
    • White papersIn-depth reports
    • DocumentationGuides & API reference
    • EventsConferences, webinars, recordings

    Subscribe

    • NewsletterSwiftly delivered
    • Join our community2,000+ web scraping engineers
  • Product and E-commerce

    From e-commerce and online marketplaces

    Data for AI

    Collect and structure web data to feed AI

    Job Posting

    From job boards and recruitment websites

    Real Estate

    From Listings portals and specialist websites

    News and Article

    From online publishers and news websites

    Search

    Search engine results page data (SERP)

    Social Media

    From social media platforms online

  • Meet Zyte

    Our story, people and values

    Contact us

    Get in touch

    Support

    Knowledge base and raise support tickets

    Terms and Policies

    Accept our terms and policies

    Open Source

    Our open source projects and contributions

    Web Data Compliance

    Guidelines and resources for compliant web data collection

    Join the team building the future of web data
    We're Hiring
    Trust Center
    Security, compliance & certifications
Login
Try Zyte APIContact Sales
All articles
AI71, 71 articles
Data quality15, 15 articles
Developer interest59, 59 articles
Integration2, 2 articles
Open-source50, 50 articles
Proxies35, 35 articles
Scraping practice35, 35 articles
Scraping strategy47, 47 articles
Search results4, 4 articles
Web data74, 74 articles
Web scraping APIs49, 49 articles
Scrapy47, 47 articles
Scrapy Cloud26, 26 articles
Web Scraping Copilot11, 11 articles
Zyte API65, 65 articles
AI & Machine Learning3, 3 articles
Automotive3, 3 articles
E-commerce & retail33, 33 articles
Entertainment & Streaming2, 2 articles
Financial Services8, 8 articles
Government2, 2 articles
Market Research & Intelligence7, 7 articles
Media & publishing11, 11 articles
Real Estate2, 2 articles
Recruitment & HR3, 3 articles
Transportation & Logistics2, 2 articles
Travel & hospitality3, 3 articles
iPaaS2, 2 articles
Large language model29, 29 articles
MCP3, 3 articles
Python110, 110 articles
Scraping at Scale7, 7 articles
Scraping Fundamentals11, 11 articles
Web Scraping Industry Report20, 20 articles

Appearance

Discord Community
BlogScraping strategyRendering Javascript pages without giving up Scrapy
ArticleTutorial / How-toScraping strategy

Rendering Javascript pages without giving up Scrapy

How to render dynamic content and work with a browser through playwright and scrapy.

John Rooney · Developer Engagement Manager

August 3, 2026

Rendering Javascript pages without giving up Scrapy

A page can contain more JavaScript than HTML and still not need a browser. Often the data is already in the response, in a script tag, or behind a JSON endpoint the application calls itself. Reaching for browser automation before checking those places is an expensive habit.

The modern approach is to treat JavaScript rendering as an acquisition choice. Keep Scrapy for scheduling, retries, item processing, pipelines, logging, and monitoring. Use an ordinary Scrapy request when it is enough, call an authorized data endpoint when that is the real source, and render in a browser only when you genuinely need the browser's DOM or state.

A JavaScript application is not the same thing as a browser-only data source.

That distinction is the difference between a crawler that scales predictably and one that launches a browser for every pagination link.

Find the data source before choosing a renderer

Start by looking at the response Scrapy receives. The Scrapy guide to dynamically loaded content suggests saving a fetched response locally, then using browser developer tools to inspect the requests that actually supply the data.

1scrapy fetch --nolog https://example.com/products > response.html
Copy

The following decision table is deliberately boring. That is a strength. It prevents a rendering problem from becoming the default architecture for the whole project.

What you find First choice Why
Data in the HTML response Normal Scrapy request and selectors Lowest operational cost and simplest failure mode
JSON-LD or serialized state in a script element Normal Scrapy request and JSON parsing The browser has nothing useful to add
A documented or otherwise authorized JSON endpoint Scrapy request to that endpoint You receive the data in its native form
Content appears only after client-side code runs scrapy-playwright for those requests A rendered DOM is the actual input
A public page needs interaction or browser-bound state A targeted browser flow and explicit context management State is part of acquisition, not an incidental header

The second and third rows are easy to miss when looking at a polished application UI. They are often the better solution.

Call the API with Scrapy when the API is the source of truth

Suppose the product page fetches a JSON document from an endpoint your project is permitted to use. Make that request with Scrapy, not with an ad hoc requests loop inside a callback. Scrapy can schedule it, retry it, log it, and feed the resulting items into the same pipeline and Spidermon checks from the earlier parts of this series.

JsonRequest serializes a JSON request body and sets the appropriate content type. The Scrapy request and response documentation also documents response.json(), which decodes the response body for you.

1# catalog/spiders/products_api.py
2import scrapy
3from scrapy.http import JsonRequest
4
5from catalog.items import Product
6
7
8class ProductsApiSpider(scrapy.Spider):
9    name = "products_api"
10    api_url = "https://api.example.com/v1/products/search"
11
12    async def start(self):
13        yield JsonRequest(
14            self.api_url,
15            data={"category": "widgets", "page": 1},
16            callback=self.parse_products,
17        )
18
19    def parse_products(self, response):
20        payload = response.json()
21
22        for product in payload["products"]:
23            yield Product(
24                name=product["name"],
25                price=product["price"],
26                description=product.get("description", ""),
27                url=response.urljoin(product["url"]),
28            )
Copy

Real APIs have their own pagination, rate limits, and terms. Treat those as part of the spider's contract. Do not treat a network-panel request as permission to use an endpoint.

This route also fits neatly with Part 2. The Product item stays the stable output contract even when the input changes from an HTML Page Object to an API response. That makes it possible to replace an acquisition strategy without rewriting exports, validation, or downstream consumers.

Add Playwright only for the requests that need a browser

When the page needs client-side rendering, scrapy-playwright is the useful integration point. It is a Scrapy download handler backed by Playwright, so rendered requests still go through Scrapy's regular scheduling and item-processing workflow.

Install the Python package and the browser binaries:

1pip install scrapy-playwright
2playwright install chromium
Copy

Then configure the handler and asyncio-compatible Twisted reactor. Recent Scrapy projects already use this reactor by default, but keeping it explicit removes an easy source of confusion when updating an older project.

1# catalog/settings.py
2DOWNLOAD_HANDLERS = {
3    "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
4}
5
6TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
Copy

That setting does not mean every HTTPS request launches Chromium. A request uses Playwright only when it carries meta={"playwright": True}. Keep listing pages, robots-aware discovery, and API calls on Scrapy's normal downloader unless rendering is specifically necessary.

Make browser rendering the project default only when it really is the default

scrapy-playwright does not provide a setting that turns on Playwright for every request. Its download handler deliberately falls back to Scrapy's normal HTTP handler unless a request has the playwright meta flag.

If a narrowly scoped project truly needs browser rendering for every request, set that flag in a downloader middleware. Downloader middlewares run before Scrapy selects the download handler.

1# catalog/middlewares.py
2class EnablePlaywrightMiddleware:
3    def process_request(self, request, spider):
4        request.meta.setdefault("playwright", True)
Copy
1# catalog/settings.py
2DOWNLOADER_MIDDLEWARES = {
3    "catalog.middlewares.EnablePlaywrightMiddleware": 543,
4}
Copy

This preserves a request that has explicitly set playwright to False, which makes an opt-out possible for a public API or static asset endpoint. In most mixed crawls, the opposite policy is easier to operate: leave the default as False and mark only the rendered paths. The scrapy-playwright activation documentation explains why registering its handler alone does not enable rendering for every request.

Wait for a meaningful element, not an arbitrary number of seconds

The common first browser spider waits five seconds and hopes. The reliable version waits for an observable condition that says the field you need has appeared.

PageMethod lets the download handler perform an action before it returns the final rendered response. The callback then receives a normal Scrapy response whose selectors see the browser-rendered DOM.

1# catalog/spiders/rendered_products.py
2import scrapy
3from scrapy_playwright.page import PageMethod
4
5from catalog.items import Product
6
7
8class RenderedProductsSpider(scrapy.Spider):
9    name = "rendered_products"
10    start_urls = ["https://example.com/widgets"]
11
12    def parse(self, response):
13        for href in response.css("a.product::attr(href)").getall():
14            yield response.follow(
15                href,
16                callback=self.parse_product,
17                meta={
18                    "playwright": True,
19                    "playwright_page_methods": [
20                        PageMethod("wait_for_selector", ".product-price"),
21                    ],
22                },
23            )
24
25    def parse_product(self, response):
26        yield Product(
27            name=response.css("h1::text").get(default="").strip(),
28            price=response.css(".product-price::text").get(default="").strip(),
29            description=" ".join(
30                response.css(".description *::text").getall()
31            ).strip(),
32            url=response.url,
33        )
Copy

The example keeps the extraction short to show the boundary. In the project from Part 2, move those selectors into the appropriate scrapy-poet Page Object once the rendering path is understood. Browser acquisition should not become an excuse to put all extraction back in callbacks.

Avoid time.sleep() and fixed browser delays. They are slow when the page is fast and unreliable when the page is slow. A selector, response condition, or explicit page action describes what the spider needs rather than guessing how long the target will take.

Treat browser pages and contexts as limited resources

A browser context holds state such as cookies and local storage. A page consumes memory and a concurrency slot. Neither should be created casually.

Use a named context when public browsing needs a coherent state, such as a chosen locale, and cap the amount of browser work your crawler can do at once:

1# catalog/settings.py
2PLAYWRIGHT_CONTEXTS = {
3    "catalog": {
4        "locale": "en-GB",
5    },
6}
7
8PLAYWRIGHT_MAX_CONTEXTS = 4
9PLAYWRIGHT_MAX_PAGES_PER_CONTEXT = 2
10PLAYWRIGHT_DEFAULT_NAVIGATION_TIMEOUT = 20 * 1000
Copy
1# catalog/spiders/localized_products.py
2import scrapy
3
4
5class LocalizedProductsSpider(scrapy.Spider):
6    name = "localized_products"
7
8    async def start(self):
9        yield scrapy.Request(
10            "https://example.com/widgets",
11            callback=self.parse_product,
12            meta={
13                "playwright": True,
14                "playwright_context": "catalog",
15            },
16        )
17
18    def parse_product(self, response):
19        return {"url": response.url}
Copy

Only set playwright_include_page when the callback must directly use the Playwright Page, for example for a multi-step interaction that cannot be expressed as PageMethod calls. Otherwise let scrapy-playwright close it after producing the response. Leaving pages open is a quiet way to stall a crawl.

Connect to a managed browser when local Chromium is not the right runtime

Local launch is the simplest starting point. It is not the only option. scrapy-playwright can connect to a browser that another system owns, which is useful when browser lifecycle, isolation, or capacity is managed outside the spider process.

Connection Setting Use it when Important constraint
Chrome DevTools Protocol PLAYWRIGHT_CDP_URL You have a remote Chromium browser exposing CDP Chromium only; local launch options are ignored
Playwright WebSocket connection PLAYWRIGHT_CONNECT_URL You connect to a Playwright server endpoint Client and server Playwright versions must be compatible

Here is a CDP connection to a remote Chromium instance:

1# catalog/settings.py
2PLAYWRIGHT_CDP_URL = "http://browser-worker.internal:9222"
3PLAYWRIGHT_CDP_KWARGS = {
4    "timeout": 20 * 1000,
5}
Copy

Do not configure PLAYWRIGHT_CDP_URL and PLAYWRIGHT_CONNECT_URL together. They are alternative connection models, and both bypass PLAYWRIGHT_LAUNCH_OPTIONS. The scrapy-playwright connection settings explain the lifecycle and compatibility implications in detail.

The operational question is more important than the setting name: who restarts a disconnected browser, who limits capacity, and how are browser logs correlated with the Scrapy job? Answer those before moving a spider to remote browsers.

Use browser-provider integrations for more options

Current scrapy-playwright releases expose PLAYWRIGHT_BROWSER_PROVIDER, an extension point for a provider that owns browser startup, connection, and teardown. The default provider supports local launch, CDP, WebSocket connections, and persistent contexts. The project also documents examples for Playwright-compatible alternatives such as Patchright and Camoufox.

These are sometimes described as stealth-browser options. That phrase promises more than the setting does. They are browser-provider integrations, not a guarantee that a target will accept a crawl or that its access rules can be ignored.

Use a provider only when you can name the concrete browser behavior or runtime constraint it addresses, and test it as a separate acquisition implementation. Keep the same Items, Page Objects, logs, and monitors around it. Respect the site's terms, permissions, and applicable requirements regardless of which browser implementation is underneath.

Monitor rendered spiders as a separate operating mode

Part 3 used Spidermon to monitor output quality. Browser-backed spiders need those checks, plus a few browser-specific signals:

  • navigation timeouts and Playwright request failures;
  • an unexpected rise in error pages or interstitial pages;
  • a drop in rendered item count compared with the expected scope; and
  • pages or contexts left open long enough to reduce crawl throughput.

A rendered response can be syntactically valid HTML and still be the wrong page. Validate the resulting item, not just the HTTP status.

Start with an item-count monitor and required-field validation. Then add a periodic monitor when a long-running browser crawl can waste meaningful capacity after a failure. The Spidermon periodic-monitoring documentation covers that pattern.

Browser automation also changes the economics of a spider. It is worth keeping a normal Scrapy path for the pages that do not need rendering, then using monitoring to prove that the smaller browser surface still produces the expected data.

Recent work on using Web Scraping Copilot in VS Code can help with the initial mechanics of a Scrapy project. It does not replace the technical judgment in the table above. The same caution appears in a review of AI-generated Scrapy projects: a spider that runs is not necessarily one with a sound acquisition strategy. For teams that need a managed alternative to operating browser infrastructure, Zyte API's headless browser capability is another option to evaluate against your requirements.

Questions developers ask about Scrapy and JavaScript rendering

Do I need Playwright because a site uses JavaScript?

No. Inspect the Scrapy response and network requests first. If the data is in HTML, serialized state, or an authorized JSON response, use Scrapy without a browser. Use Playwright when the rendered DOM, client-side interaction, or browser state is genuinely required.

Can one spider mix ordinary and rendered requests?

Yes. That is usually the point. Register the download handler once, then set meta={"playwright": True} only on the individual requests that need it. The rest continue through Scrapy's standard downloader.

When should I use CDP instead of PLAYWRIGHT_CONNECT_URL?

Use CDP when the remote browser is Chromium and exposes a CDP endpoint. Use the Playwright connection URL for a Playwright server endpoint. Pick one connection model, document who owns browser lifecycle, and test reconnect behavior before relying on it in scheduled crawls.

Should I enable a stealth-oriented browser provider by default?

No. Start with the default provider and add another only for a defined, permitted requirement. A provider increases the number of moving parts, so it should earn its place through a concrete operational need, not a vague expectation that every modern site needs it.

Keep the browser at the edge of the architecture

The series began with Scrapy's core components, moved extraction into Page Objects, and added monitoring around the data contract. Browser rendering belongs at the edge of that architecture: it changes how a selected response is acquired, not how the rest of the project is designed.

For your next JavaScript-heavy target, inspect one product page and one listing page. Write down where each field comes from. Then choose the least expensive permitted acquisition method that gives you that source, and make the resulting Items pass the same checks as every other spider.

Try Zyte API

Build your first scraper in minutes

Free trial, no credit card. From a single request to production in an afternoon.

Get started
Scraping strategy

John Rooney

Developer Engagement Manager

John is the Developer Engagement Manager at Zyte, working closely with the community, creating content and helping developers learn web scraping, Zyte products an much more. He has spoken at Extract Summit's and also creates the workshop's for the events.

  • X (Twitter)
  • LinkedIn
More from this author

In this article

  • Find the data source before choosing a renderer
  • Call the API with Scrapy when the API is the source of truth
  • Add Playwright only for the requests that need a browser
  • Make browser rendering the project default only when it really is the default
  • Wait for a meaningful element, not an arbitrary number of seconds
  • Treat browser pages and contexts as limited resources
  • Connect to a managed browser when local Chromium is not the right runtime
  • Use browser-provider integrations for more options
  • Monitor rendered spiders as a separate operating mode
  • Questions developers ask about Scrapy and JavaScript rendering
  • Do I need Playwright because a site uses JavaScript?
  • Can one spider mix ordinary and rendered requests?
  • When should I use CDP instead of PLAYWRIGHTCONNECTURL?
  • Should I enable a stealth-oriented browser provider by default?
  • Keep the browser at the edge of the architecture

Follow

Get the latest

Zyte and the data web in your inbox — or wherever you already are.

Subscribe

Or follow elsewhere

Continue reading

How to build your first Scrapy extension
Scraping strategy

How to build your first Scrapy extension

Why my Scrapy project plays a triumphant fanfare when a crawl finishes clean and a sad trombone when it doesn't, and how I finally learned how to build Scrapy extensions (it's easy)

Ayan Pahwa·June 18, 2026

The Community · Newsletter

The best of Zyte and the data web, in your inbox.

One curated edition — new articles, product updates, and the stories shaping the data web. No noise.

Services

Zyte Data

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Explore Zyte Data

Web Scraping API

Zyte API

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Sign Up

Developers

Zyte Developers

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Join Us
    • Zyte API
    • Ban Handling
    • AI Extraction
    • SERP
    • Enterprise
    • Scrapy Cloud
    • Agentic Web Data
    • Pricing
    • Product & E-commerce
    • Data for AI
    • Job Posting
    • Real Estate
    • News & Articles
    • Search
    • Social Media
    • Blog
    • Learn
    • Case Studies
    • Webinars
    • White Papers
    • Join our community
    • Documentation
    • Meet Zyte
    • Contact us
    • Jobs
    • Support
    • Terms and Policies
    • Trust Center
    • Do not sell
    • Cookie settings
    • Web Data Compliance
    • Open Source
    • What is Web Scraping
    • Web Scraping in Python: Ultimate Guide
    • Stop getting blocked, start scraping
  • EWDCI logoMost loved workplace certificateZyte rewardISO 27001 iconG2 rewardG2 rewardG2 reward
    XFacebookInstagramYouTubeLinkedInDiscord

    © Zyte Group Limited 2026