Scrapy Playwright Integration for Dynamic Page Scraping
Browser rendering for specific requests keeps Scrapy crawls fast and cost-efficient.

Scrapy handles crawl orchestration at a scale few tools can match. Playwright renders JavaScript that Scrapy cannot see on its own. Scrapy-playwright exists because modern web pages often need both, and the real skill in using it is knowing when not to reach for the browser at all. Most teams get this backwards: they either flip Playwright on everywhere because it's easier to reason about, or they skip it everywhere and can't figure out why half their pages come back blank. Both instincts are wrong, and this piece is mostly about why.
What scrapy-playwright actually is and how it connects the two tools
At its core, scrapy-playwright is a download handler. That word matters more than it sounds like it should. Scrapy's architecture separates concerns cleanly: a scheduler decides what to fetch next, a downloader fetches it, middlewares process it, pipelines store the result. Scrapy-playwright slots into that downloader position for HTTP and HTTPS requests, but it doesn't take over the whole pipeline. Scrapy still owns scheduling, deduplication, and the item pipeline. All scrapy-playwright changes is what happens during the actual fetch.
The switch is per-request, not global. A request only routes through a real browser when a metadata flag tells it to. Leave the flag off, and Scrapy fetches the page the old way: a plain HTTP request, parsed almost instantly, no browser overhead at all. Flip it on, and the request goes through Playwright, which opens a browser context, navigates to the page, waits for whatever conditions were specified, and hands the rendered HTML back to Scrapy as if nothing unusual happened.
Static pages, category listings, sitemaps, and anything that doesn't depend on client-side JavaScript should never touch Playwright. In a crawl of 50,000 URLs where only a fraction of pages actually run client-side rendering, routing all 50,000 through a browser context is the single most common way teams blow their infrastructure budget on a scraper that didn't need one. Browser rendering costs memory, CPU, and wall-clock time. Paying that cost for a page that would've parsed fine as plain HTML isn't caution, it's waste.
For version context: this setup assumes scrapy-playwright 0.0.40 or later, running on Scrapy 2.11 or newer, with Python 3.12. Scrapy-splash belongs in this conversation too, as the older sibling in this space. Splash is essentially unmaintained as of 2024–2025, so calling it abandoned is not far off the mark. But Splash's JavaScript engine is old enough that plenty of modern single-page applications won't render correctly through it, and the project's unmaintained status makes it a poor foundation for new work. Unless there's a specific legacy reason to stick with Splash, scrapy-playwright is the current answer, not a matter of taste.
What does Playwright actually bring once it's wired in? Full browser rendering across Chromium, Firefox, and WebKit, plus the ability to click buttons, fill forms, scroll, wait for specific elements or network events, take screenshots, and run arbitrary JavaScript inside the page. Scrapy keeps everything that makes it good at scale: the middleware stack, item pipelines, request prioritization, deduplication, whatever output format the project needs. Nothing about the rest of the crawl has to change just because a handful of pages now go through a browser.
Installation and the settings.py configuration that makes it work
Getting scrapy-playwright running takes a short sequence of steps, and skipping any one of them tends to produce a confusing failure rather than a clear error message.
Start with pip install scrapy scrapy-playwright, then run playwright install, which downloads the actual browser binaries (Chromium, Firefox, WebKit) that Playwright drives. Skipping this step is the single most common installation trap: the Python package installs fine, but there's no browser for it to launch, so the first real request just fails, often with an error that points nowhere near the actual cause. Confirm versions afterward: scrapy version should report 2.7 or later, scrapy-playwright should be at 0.0.40 or above.
Project setup doesn't change at all. scrapy startproject, then scrapy genspider, same as any other Scrapy project. The differences live entirely in settings.py, where three entries do the real work.
First, DOWNLOAD_HANDLERS needs both the http and https keys pointed at scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler. Second, TWISTED_REACTOR needs to be set to twisted.internet.asyncioreactor.AsyncioSelectorReactor. Third, PLAYWRIGHT_BROWSER_TYPE picks which browser engine to launch: chromium, firefox, or webkit.
That second setting deserves a beat of explanation, because it causes more wasted debugging hours than anything else in this stack. Scrapy runs on Twisted, an event-driven networking framework that predates asyncio in the Python ecosystem by years. Playwright is built on asyncio. Two different event loops in the same process don't share resources automatically, and without the asyncio reactor explicitly set, the two frameworks talk past each other. The failure mode can be difficult to diagnose: stalled requests, timeouts that don't line up with anything in the code, errors that point nowhere useful. As of Scrapy 2.7, this reactor became the default, so newer projects may not need to set it by hand. Older setups still should, explicitly, rather than assume the framework will figure it out.
One more setting worth configuring early: PLAYWRIGHT_DEFAULT_NAVIGATION_TIMEOUT, which defaults to 30 seconds (expressed in milliseconds in the config). Pages that load slowly, or that depend on several background network calls before the content settles, often need this raised, or every navigation on a slow site starts timing out for no reason anyone can see in the logs.
A caveat worth keeping in mind: scrapy-playwright's asyncio event loop integration can behave differently across operating systems. Testing on the same OS you deploy to, typically Linux, is the safest approach, regardless of what's convenient on a laptop.
The per-request opt-in pattern: how a spider mixes fast and slow requests
Inside a spider, the decision to use Playwright happens at the request level, one line in a meta dictionary: "playwright": True. That's the entire switch. Everything else about writing a scrapy.Request stays the same.
For anything beyond a simple page load, one flag isn't enough. Set playwright_include_page=True and Scrapy hands back the live Playwright Page object inside response.meta["playwright_page"]. This is what makes post-load interaction possible: clicking a button after the page renders, filling a search field, waiting on a specific network call. But it comes with a housekeeping responsibility that gets skipped constantly, and this is worth being blunt about: that page object has to be closed, explicitly, with await page.close(), inside the parse callback. It has to be closed in the errback too, because a request that errors out before reaching the parse callback still leaves a browser page open in memory if nothing closes it. Skip this in a spider crawling tens of thousands of pages, and memory climbs steadily until the process falls over well into a run that looked fine at the start.
For sequences of actions, PageMethod objects handle the choreography. A list of them goes into "playwright_page_methods" in the request meta, each one naming a Playwright method and its arguments, executed in order before the response comes back to Scrapy. A typical sequence: wait for a search input to appear, fill it with a query, click submit, then wait for the results container to load. All of it gets declared upfront, in meta, before the request is even dispatched. No custom async function needed for the common cases.
Pagination works almost the same as it does in a plain Scrapy spider. Inside the callback, yield a new scrapy.Request for the next page, keep playwright=True in its meta, and Scrapy's scheduler and deduplication filter handle the rest, exactly the way they would for any static crawl. The parts of Scrapy that make it good at scale, queueing, retrying, avoiding duplicate fetches, don't care whether a request went through a browser or not.
Once the rendered HTML comes back, it behaves like any other Scrapy response. response.css() and response.xpath() both work against the fully rendered DOM, not the original server HTML. A job listings page that only populates after scrolling or applying a filter becomes solvable this way: wait for the scroll or filter interaction to complete via PageMethod, then let Scrapy's normal selector methods pull the data out.
Handling authentication and session state across requests
Playwright organizes everything session-related, cookies, localStorage, cached credentials, into browser contexts. Each context is isolated from every other one, which turns out to be a convenient unit for handling login flows inside a scraping project.
The pattern: log in once, through a browser-driven flow, then call context.storage_state(path="auth.json"). That serializes the cookies and localStorage from the session into a file. From that point forward, any Scrapy request that needs to act as that logged-in user can load the same state by passing "playwright_context_kwargs": {"storage_state": "auth.json"} in its meta. A fresh browser context spins up already carrying the saved session, no need to repeat the login sequence for every single request.
This scales sideways too. Playwright's context isolation makes it straightforward to keep session-specific data from bleeding between users, since each context is fully independent by design. That's a meaningfully different problem from single-session scraping, and browser contexts solve it cleanly because the isolation is built into Playwright itself, not bolted on after the fact.
For simpler cases, token-based auth for instance, cookies can be set directly on the context through playwright_context_kwargs without needing a saved state file at all.
At the pipeline level, none of this session complexity leaks downstream. The login handling and context management stay contained upstream of the parsing and storage logic, so the item pipeline sees scraped data regardless of how authentication was handled.
Resource interception and bandwidth optimization inside Playwright requests
A Playwright request, left alone, loads everything: images, web fonts, third-party analytics scripts, ad tracking pixels. None of that helps a scraper trying to pull product prices or job titles off a page, and all of it costs time and, when proxies are involved, bandwidth that's often billed by the gigabyte.
Playwright's page.route() method intercepts every outgoing request the browser makes, before it reaches the remote server, and lets the code decide whether to let it through, block it, or modify it. Block images, fonts, media files, and stylesheets by default. They almost never affect the data being extracted. Block analytics and tracking domains too, less for the bandwidth and more because they add JavaScript execution time to pages that are already complex enough without them.
The savings here aren't marginal, and this is where the argument for interception stops being theoretical. On a large crawl running through paid residential or datacenter proxies, bandwidth costs are often billed by the gigabyte, so that difference shows up directly on the invoice, not as an abstract efficiency gain.
Implementing this inside scrapy-playwright means accessing the page object (via playwright_include_page=True) and calling page.route() before navigation completes, so the blocking rules are in place before the browser starts pulling in assets.
Leaner requests, ones that skip images and third-party scripts, also use less memory per browser context. That connects directly to the next problem, because memory is the resource that decides how many of these contexts can run at once.
Concurrency, memory, and the real resource cost of running Playwright at scale
A single Playwright container, running one browser context, sits at roughly 500MB to 1GB of memory at idle. Under load, with multiple contexts open at once, that climbs to 2GB or more. A plain Scrapy request uses a small, fairly fixed amount of memory regardless of how many run in parallel. The gap between the two isn't subtle, and teams that size their infrastructure around Scrapy's normal footprint get blindsided the moment Playwright enters the picture.
CPU load is erratic too, bursty, tied directly to how complex a given page's JavaScript is. That makes it harder to plan capacity around than a workload with predictable, even resource use, and it's a large part of why naive autoscaling rules built for stateless HTTP workers tend to misfire on Playwright fleets.
Scale that out and the math gets serious fast. A hundred parallel browser contexts running in a production scraping fleet isn't an unusual setup, and at that scale, the memory bill adds up faster than most teams budget for going in. This is exactly why the per-request opt-in pattern from earlier matters as much as it does: keeping the ratio of browser-rendering-flagged requests low, relative to the total crawl, is the single biggest lever for controlling peak memory use. Not proxy rotation. Not concurrency tuning. The ratio, and nothing else on this list comes close.
The crawler's concurrency setting governs how many requests run at once, and for browser-rendering-flagged requests specifically, that setting is effectively a cap on how many browser contexts can be open simultaneously. Set it too high without accounting for Playwright's memory footprint, and a crawler that looked fine in testing starts getting killed by an out-of-memory error in production, usually at the worst possible time.
Discipline around closing sessions matters as much as the settings do. Browser instances that don't get explicitly closed after use are one of the most common causes of production failures in long-running spiders: a slow memory leak that looks perfectly fine for the first hour and then brings the whole process down six hours in, with nothing in the logs pointing directly at the cause.
More concurrency buys more throughput, but it costs more memory, in a fairly direct trade-off. That tension is what shapes the deployment decisions in the next section.
Deploying scrapy-playwright in Docker and Kubernetes
Docker earns its place here for an unglamorous reason: dependency consistency. Playwright needs specific system libraries to run its browsers, and those libraries differ across Linux distributions and versions in ways that produce "works on my machine" problems constantly. Docker sidesteps that by shipping the browser and its dependencies bundled into the image itself.
Microsoft maintains official Playwright Docker images, hosted at mcr.microsoft.com/playwright, covering Node, Python, Java, and.NET, each pinned to a specific Playwright version. That pinning matters more than it sounds like it should: browser binaries and the Playwright library version need to match closely, and drifting between them is a reliable source of odd, hard-to-debug failures that look like application bugs but aren't.
Plan for one trade-off upfront: these images run considerably larger than a typical Python service image, because they carry full browser binaries. That affects registry storage costs and pull times in CI/CD pipelines, and it's worth factoring into deployment timing before a build pipeline gets tight, not after.
There's a leaner alternative for teams that want smaller worker images: connect to a remote browser service over a remote debugging protocol, rather than bundling a browser engine directly inside each worker container. The worker stays lightweight, running just the Scrapy spider and its dependencies, while a separate service handles the actual browser process. This also separates scaling concerns, since the browser layer can scale independently from the application layer, which matters once a deployment grows past a handful of containers.
On the Kubernetes side, the choice between a Job and a CronJob depends on the shape of the crawl. A Kubernetes Job models a bounded run: start, scrape, finish, terminate. A CronJob, paired with concurrencyPolicy: Forbid, handles recurring scrapes on a schedule without letting two runs overlap. That overlap protection matters more than it sounds like it should, because overlapping runs on a slow-loading site can produce duplicate data collection if one run hasn't finished by the time the next one starts.
Deployment tends to surface a set of problems that never show up in local testing: browser startup behavior inside a container, proxy and credential handling across pods, how retries behave when a browser crashes mid-request, how script-heavy pages sometimes render differently under repeated automated visits versus a single manual test. None of these are syntax errors. They're operational issues that only surface once the crawler runs continuously, at volume, in an environment that isn't a laptop.
Kubernetes' native pod autoscaling, tied to resource usage, pairs naturally with the memory profile discussed earlier. Once the memory cost per browser context is known with some precision, setting meaningful autoscaling thresholds becomes arithmetic, not guesswork.
Integrating scrapy-redis and scrapy-impersonate without breaking Playwright routing
Scrapy-redis replaces Scrapy's default scheduler and duplicate filter with Redis-backed versions, which is what makes distributed crawling across multiple worker processes possible. Several machines pull from the same request queue and share the same deduplication filter, coordinated through a shared in-memory data store rather than kept in memory on a single process.
The friction shows up when scrapy-impersonate enters the picture alongside scrapy-playwright. Both are download handlers, and both want to own the same http and https keys in DOWNLOAD_HANDLERS. Only one handler can hold a given scheme at a time, so registering both against the same keys doesn't throw a clear error. It just breaks one of them silently, and the crawler keeps running with the wrong handler in place.
Route the second handler through its own meta key or a separate scheme entry, rather than fighting over the same keys. Which handler takes priority for a given request needs to be a deliberate per-request decision, not whichever handler happened to register last in settings.py.
Here's the part worth being precise about, because scrapy-impersonate gets oversold constantly. It changes transport-level fingerprints: the TLS handshake, HTTP/2 signal patterns, the JA3 fingerprint anti-bot systems use to identify what kind of client is making a request. What it does not touch: IP reputation, cookie history, JavaScript execution behavior, or the broader category of behavioral signals that modern detection systems build profiles around. Passing a TLS fingerprint check is a real win, but it's one layer out of several. Treating it as a stand-in for the rest is the mistake that shows up fast once a target site runs anything more sophisticated than a TLS check.
Scrapy-redis handles distributed scheduling, scrapy-playwright handles rendering, scrapy-impersonate patches exactly one detection layer while leaving the others fully exposed. Treating any one of them as sufficient on its own is where scraping infrastructure runs into trouble in production, usually right after it passed every test in staging.
How anti-bot systems detect Playwright and what that means for scraping strategy
The cheapest detection check available to any anti-bot script is navigator.webdriver. Every Playwright browser instance sets that property to true as a deliberate design choice, not a bug or an oversight. It's a requirement written into the W3C WebDriver spec itself. Anti-bot systems check it first precisely because it costs almost nothing to check and catches a meaningful share of naive automation attempts immediately.
So why does Playwright still get used successfully at all, if the tell is that cheap and that well known? Because navigator.webdriver is the first layer, not the whole stack. Modern anti-bot systems build up multiple layers: canvas fingerprinting, rendering-engine signature checks, timing patterns in script execution, mouse movement and scroll behavior, and consistency between a browser's claimed identity and how it actually behaves across a session. A crawler that spoofs its user agent string but still moves through a page in perfectly straight, perfectly timed intervals has just traded one obvious tell for a slightly less obvious one.
That's worth sitting with. Passing one layer of detection doesn't mean passing the whole stack, the same way patching one open port doesn't mean a server is secure. Scrapy-impersonate handles the TLS and HTTP/2 fingerprint layer. Resource blocking through page.route() reduces the JavaScript footprint behavioral analysis might scrutinize. Neither addresses navigator.webdriver on its own, and neither replicates the mouse and scroll entropy of an actual human session.
What does this mean, practically, for anyone building against a site with serious anti-bot defenses? Treat detection as a layered problem, not a single obstacle to clear. Fingerprint spoofing solves one layer. Careful session and cookie handling solves another. Resource interception shrinks the behavioral surface a script has to fake convincingly. None of these substitute for the others, and stacking all of them still doesn't guarantee success against a system built specifically to catch automated browsers, since detection techniques keep evolving on the other side too. This is an ongoing back-and-forth, not a problem with a final, permanent fix, and any scraping strategy that treats it as solved is the one that breaks first.
Sources
- The Scrapy Playwright Tutorial (2026)
- Scrapy Playwright tutorial
- Scrapy Playwright Tutorial: How to Scrape JavaScript Websites
- Playwright in Docker: A Strategic Guide for 2026
- Scrapy Playwright Tutorial: How to Scrape Dynamic Websites
- Scrapy Playwright: Complete Tutorial 2026 · Zenrows
- Scrapy Playwright Tutorial: Scrape Dynamic Websites
- github.com


