Scraping JavaScript Pages With Scrapy
Scrapy downloads HTML but never runs JavaScript, so on pages that build their content in the browser you have three add-ons to choose from: scrapy-playwright, which renders chosen requests in a real Chromium, Firefox or WebKit browser and is the maintained default (version 0.0.48, July 2026); scrapy-splash, which sends pages to a separate Splash rendering service; and scrapy-selenium, which has had no release since January 2019. Before you add any of them, check the Network tab: if the page loads its data from a JSON request, a plain Scrapy request to that URL is faster than any browser. ProxyField residential proxies go into scrapy-playwright's browser or context settings, from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.
- residential IPs
- 412M+
- from, traffic never expires
- $1.95/GB
- Easy Trial
- 1 GB for $2.95
On this page 8 sections
Why Scrapy returns empty results on JavaScript pages
Scrapy's downloader fetches the HTML the server sends and hands it to your callback. Anything a script adds after load (a table filled by an AJAX call, prices from a React app, reviews behind "Load more") is not in that response. The usual symptom is a selector that works in your browser's DevTools and returns nothing in Scrapy.
Check it in the Scrapy shell:
scrapy shell "https://www.scrapethissite.com/pages/ajax-javascript/"
>>> response.css("tr.film").getall()
[]
>>> view(response) # opens what Scrapy received in your browserOn this practice page, the Oscar films table is empty in the HTML; a click on a year runs a jQuery request to ?ajax=true&year=2015 and fills the table from the JSON it returns. The general method for spotting this is in scrape dynamic web pages.
First choice: request the JSON directly
If the data arrives as JSON, skip the browser. Ask Scrapy for the same URL the page calls:
import scrapy
class FilmsApiSpider(scrapy.Spider):
name = "films_api"
async def start(self):
for year in range(2010, 2016):
yield scrapy.Request(
f"https://www.scrapethissite.com/pages/ajax-javascript/?ajax=true&year={year}"
)
def parse(self, response):
for film in response.json():
yield {"title": film["title"].strip(), "year": film["year"], "awards": film["awards"]}On 2026-10-23 this spider returned 87 films for the six years in six small requests. This is the fastest and lightest way to scrape a JavaScript page: no browser, a few kilobytes per response, Scrapy's normal concurrency. It fails when the request needs tokens that only the page's script can compute, and that is when you render. Finding and replaying these requests is covered in scraping hidden APIs.
async def start() replaced start_requests() in Scrapy 2.13.0, and from Scrapy 2.16.0 on start_requests() is removed and no longer called (Scrapy release notes). The current Scrapy release is 2.19.0 (PyPI, 2026-10-23).
scrapy-playwright: render pages in a real browser
scrapy-playwright (BSD-3-Clause, Python 3.10+) replaces Scrapy's download handler. Requests you mark with meta={"playwright": True} are loaded in a Playwright browser; all other requests still go through Scrapy's normal downloader.
pip install scrapy scrapy-playwright
playwright install chromiumimport scrapy
from scrapy_playwright.page import PageMethod
class FilmsSpider(scrapy.Spider):
name = "films"
custom_settings = {
"DOWNLOAD_HANDLERS": {
"http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
"https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler",
},
"TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor",
"DOWNLOAD_DELAY": 2,
}
async def start(self):
for year in (2015, 2014):
yield scrapy.Request(
f"https://www.scrapethissite.com/pages/ajax-javascript/#{year}",
meta={
"playwright": True,
"playwright_page_methods": [PageMethod("wait_for_selector", "tr.film")],
},
cb_kwargs={"year": year},
dont_filter=True,
)
def parse(self, response, year):
for row in response.css("tr.film"):
yield {
"year": year,
"title": row.css("td.film-title::text").get("").strip(),
"awards": int(row.css("td.film-awards::text").get("0")),
}Run it with scrapy runspider films_spider.py -O films.json. On 2026-10-23 it returned 32 films for the two years. Three details in it:
PageMethod("wait_for_selector", "tr.film")waits until the script has filled the table. Without a wait, the response is taken when the page's load event fires, and on this page the rows come later. You can chain other page methods the same way:click,fill,evaluate,wait_for_timeout.dont_filter=Trueis needed here because Scrapy's duplicate filter ignores the#2015fragment, so the second URL would count as a repeat of the first.- The asyncio reactor has been the default in new Scrapy projects since 2.7; set it anyway in a spider that runs on its own.
When you need to act on the page inside your callback (scroll, click "Load more", read a value from JavaScript), pass "playwright_include_page": True, take the page from response.meta["playwright_page"], and always await page.close(), also in an errback. Open pages count against PLAYWRIGHT_MAX_PAGES_PER_CONTEXT, and a crawl that leaks them can freeze.
Make scrapy-playwright faster and lighter
A browser downloads everything a page references. Block what you don't need, and cap how many browser pages run at once:
def should_abort_request(request):
return request.resource_type in {"image", "media", "font"}
custom_settings = {
# ... the settings above, plus:
"PLAYWRIGHT_ABORT_REQUEST": should_abort_request,
"PLAYWRIGHT_MAX_CONTEXTS": 4,
"PLAYWRIGHT_MAX_PAGES_PER_CONTEXT": 4,
"CONCURRENT_REQUESTS": 8,
}Render only the pages that need it. A common pattern is a plain Scrapy request for the listing and meta={"playwright": True} only for the detail pages that load their content by script, or the other way around.
scrapy-splash: a rendering service over HTTP
Splash is a headless browser that runs as an HTTP service, usually in Docker on port 8050, and scrapy-splash (0.11.1, February 2025) sends Scrapy requests to it. You start the Splash container as its installation docs describe, then add the settings from the scrapy-splash README (SPLASH_URL, its downloader and spider middlewares, the dupe filter and request fingerprinter) and use SplashRequest:
from scrapy_splash import SplashRequest
yield SplashRequest(url, self.parse, args={"wait": 2})Splash scripts are written in Lua, which lets one request scroll, click and return several values. The trade-offs: the last Splash release is 3.5, from June 16, 2020 (changelog), and its rendering engine is older than current browsers, so some modern sites render incompletely. A render times out after 30 seconds by default and can't exceed 90 unless you start Splash with --max-timeout (Splash HTTP API). Splash still makes sense in a project that already uses it, or when you want rendering on a separate server that several spiders share. For new projects, scrapy-playwright is simpler.
Selenium with Scrapy
You can combine them, but there is no maintained plugin. scrapy-selenium 0.0.7 dates from January 24, 2019; its middleware passes an executable_path argument and a browser-specific options argument (such as chrome_options) to the WebDriver, while the current Selenium, 4.51.0, accepts only options, service and keep_alive. With current Selenium it fails when the spider starts, unless you pin an old Selenium or patch the middleware.
Two working routes if you need Selenium:
- A small downloader middleware of your own that loads marked requests in a WebDriver and returns
HtmlResponse(url, body=driver.page_source, encoding="utf-8", request=request). Selenium's calls block, so Scrapy waits while each page renders; keep concurrency low or run several processes. - Two stages: Scrapy collects the URLs, and a separate Selenium script renders only the pages that need it.
If Selenium is not a requirement, scrapy-playwright does the same job inside Scrapy's event loop. The broader comparison is in Playwright vs Selenium.
Which one to use
| Feature | Direct JSON request | scrapy-playwright | scrapy-splash | Selenium with Scrapy |
|---|---|---|---|---|
| Runs JavaScript | No (you call the data URL) | Yes, Chromium, Firefox or WebKit | Yes, Splash's own engine | Yes, any WebDriver browser |
| Extra service | None | None (browser in-process) | Splash in Docker | WebDriver and browser |
| Interaction | None | Page methods or the page object | Lua scripts | WebDriver API |
| Maintained (October 2026) | n/a (Scrapy itself, 2.19.0) | Yes, 0.0.48, July 2026 | Plugin 2025; Splash 2020 | Plugin 2019 |
| Speed and traffic | Fastest, smallest | Slower, heavier | Slower, heavier | Slowest, blocks the reactor |
| Use it when | The data comes from an API call | The page needs a browser | You already run Splash | Selenium is required |
Proxies with scrapy-playwright
Requests that Playwright loads take their proxy from the browser or context, not from the proxy meta key that Scrapy's normal downloader uses. Set it at launch for the whole browser, or per context so that different requests leave through different IPs (both forms are in the scrapy-playwright README):
custom_settings = {
"PLAYWRIGHT_LAUNCH_OPTIONS": {
"proxy": {
"server": "http://gate.proxyfield.io:7000",
"username": "<login>-country-us-city-new_york",
"password": "<password>",
},
},
}gate.proxyfield.io:7000 is the ProxyField gateway; <login>-country-us-city-new_york asks for an exit IP in New York. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. The gateway can give each request a new IP, or keep one IP for a sticky session from 30 s to 36 h. A browser page loads dozens of resources, so when a page must come from one IP from start to finish, use a sticky session, one per context, and set the contexts in PLAYWRIGHT_CONTEXTS. Requests that skip Playwright still use Scrapy's own proxy setup, shown in Scrapy proxy setup.
Rendered pages cost more traffic than HTML alone, because the browser also loads scripts, styles and images. Blocking images and fonts with PLAYWRIGHT_ABORT_REQUEST cuts most of that, and the JSON-request route uses least of all. ProxyField bills per GB and the traffic never expires, so you can measure a test run on the Easy Trial before you buy more.
Rendering JavaScript pages in Scrapy? ProxyField residential proxies: 412M+ residential IPs in 195+ countries, a new IP on every request or sticky sessions up to 36 h, from $1.95/GB with no subscription; start with the Easy Trial (1 GB). Traffic never expires, and all purchases are final.
More on Scrapy itself: the Scrapy tutorial builds a first spider step by step, and what is Scrapy explains how it works.
Scraping JavaScript Pages With Scrapy FAQ
How do I use Scrapy with Playwright?
ProxyField residential proxies, from $1.95/GB, go into the launch or context settings of a three-step setup: pip install scrapy-playwright and playwright install chromium, set the scrapy-playwright download handler for http and https plus the asyncio reactor, and add meta={"playwright": True} to the requests that need a browser. Add PageMethod("wait_for_selector", ...) so the response is taken after the content has loaded.
Can I use Selenium with Scrapy?
Yes, but the scrapy-selenium plugin has not been updated since January 2019 and does not start with current Selenium. Write a small downloader middleware that renders marked requests in a WebDriver, or let Scrapy collect URLs and render them in a separate Selenium step. scrapy-playwright does the same job with less code.
Does Scrapy work with JavaScript?
Not by itself: Scrapy parses the HTML the server returns and never runs scripts. Call the JSON request the page makes, or add a renderer such as scrapy-playwright or Splash for the pages that need one.
Can Scrapy replace Selenium?
For pages whose data is in the HTML or in a JSON request, yes, and it is much faster at it. For pages that need clicks, scrolling or script execution, Scrapy needs a renderer such as scrapy-playwright, which gives you the browser control you would otherwise use Selenium for.
How does the Scrapy download handler integrate with Playwright?
scrapy-playwright registers its own download handler for http and https. Requests with meta["playwright"] set are loaded in a Playwright browser page and returned as a normal Scrapy response with the rendered HTML; all other requests fall through to Scrapy's default handler.
What is the role of async def in Scrapy Playwright spiders?
async def in Scrapy Playwright spiders?Playwright's Python API used by scrapy-playwright is asynchronous, so callbacks that work with the page object (response.meta["playwright_page"]) must be async def and await its methods, such as await page.click(...) and await page.close(). Since Scrapy 2.13 the spider's entry point is also async def start().
How can I manage multiple browser contexts in a Scrapy project using Playwright?
Define named contexts in PLAYWRIGHT_CONTEXTS, each with its own settings such as a proxy, locale or viewport, and choose one per request with meta["playwright_context"]. Cap them with PLAYWRIGHT_MAX_CONTEXTS, and close contexts you create while crawling when you are done with them.
How should I manage proxy sessions in Scrapy Playwright?
Give each context its own proxy credentials in PLAYWRIGHT_CONTEXTS, so one context keeps one sticky IP while another uses a different one. A browser page makes many requests, so use a sticky session when the whole page must load from the same IP, and rotate between contexts rather than inside one page.
What is Scrapy Splash?
Splash is a headless browser that runs as an HTTP service, usually in Docker, and scrapy-splash is the plugin that sends Scrapy requests to it for rendering. Splash scripts are written in Lua; its last release, 3.5, came out in June 2020.
What is the maximum timeout in Scrapy Splash?
A render times out after 30 seconds by default, and the largest timeout a request may ask for is 90 seconds. To allow longer renders, start Splash with the --max-timeout option, for example --max-timeout 300.
Show 2 more questionsShow fewer questions
What are the alternatives to Scrapy Splash?
scrapy-playwright is the usual replacement: it drives current Chromium, Firefox or WebKit builds inside Scrapy, without a separate service. Other options are calling the site's JSON requests directly, or rendering pages in a separate Playwright or Selenium script.
Is there a Python Scrapy alternative that handles JavaScript automatically?
Not one that renders without a browser: every tool that runs JavaScript drives a browser underneath, whether it is Playwright, Selenium or a crawling framework built on them. Within Scrapy, scrapy-playwright is the closest to automatic, since one meta key sends a request through a real browser.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.