Web Scraping With Playwright
Web scraping with Playwright means letting a real Chromium, Firefox or WebKit browser load the page, run its JavaScript and then reading the data through locators such as page.locator(".price"), from Python, Node.js, Java or .NET. Playwright waits for elements by itself, so a working scraper is about 20 lines: launch, goto, wait for the data, read it, close. ProxyField's residential proxies plug into Playwright's proxy option per browser or per context, with 412M+ IPs in 195+ countries 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 12 sections
- Is Playwright good for web scraping?
- Install Playwright
- Your first Playwright scraper in Python
- Locators and selectors
- Waiting: page loads, waitFor and timeouts
- Pagination and following links
- Scraping infinite scroll with Playwright
- Taking screenshots with Playwright
- Speed and scale: contexts, async and blocking resources
- Playwright scraping best practices
- Using proxies with Playwright
- Web Scraping With Playwright FAQ
Is Playwright good for web scraping?
Yes, for JavaScript-rendered pages and multi-step flows. Playwright is Microsoft's open-source browser automation library (Apache-2.0), built for testing but just as useful for scraping (playwright.dev). Its strengths for scraping:
- Auto-waiting. Actions and locators wait until an element is attached, visible and stable, so you write fewer manual waits.
- Browser contexts. One browser can run many isolated sessions, each with its own cookies and its own proxy, at a fraction of the cost of new browsers.
- Network control. You can block images and fonts, or read the JSON responses the page loads, from the same script.
- Four languages, three engines. The same API in Python, Node.js, Java and .NET, on Chromium, Firefox and WebKit.
Use a plain HTTP client instead when the data is in the page source: it is many times faster and uses a fraction of the traffic. What Playwright is, its license and how it differs from Selenium are covered in what Playwright is.
| Situation | Use |
|---|---|
| Data is in the HTML source | An HTTP client and a parser (Requests and Beautiful Soup, or fetch and Cheerio) |
| Data loads from a JSON endpoint you can see in DevTools | Call the endpoint directly |
| Data appears only after scripts run, scrolls or clicks | Playwright |
| Many pages, mixed static and dynamic | A crawler framework that calls Playwright only for the dynamic pages |
Install Playwright
Playwright installs as a library plus the browser builds it is tested against.
# Python
pip install playwright
playwright install chromium
# Node.js
npm i playwright
npx playwright install chromiumplaywright install without a name downloads Chromium, Firefox and WebKit; on a fresh Linux server, add --with-deps to install the system libraries the browsers need (playwright.dev: Browsers).
Your first Playwright scraper in Python
This script opens a practice page whose quotes are written in by JavaScript, waits for them and saves each quote, author and tag list to JSON.
# scrape.py -> python scrape.py
import json
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch() # headless by default
page = browser.new_page()
page.goto("https://quotes.toscrape.com/js/", wait_until="domcontentloaded")
page.locator(".quote").first.wait_for() # wait for the data, not a timer
quotes = [
{
"text": card.locator(".text").inner_text(),
"author": card.locator(".author").inner_text(),
"tags": card.locator(".tag").all_inner_texts(),
}
for card in page.locator(".quote").all()
]
browser.close()
json.dump(quotes, open("quotes.json", "w"), indent=2)
print(len(quotes), "quotes saved")The same scraper in Node.js:
// scrape.mjs -> node scrape.mjs
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://quotes.toscrape.com/js/', { waitUntil: 'domcontentloaded' });
await page.locator('.quote').first().waitFor();
const quotes = await page.locator('.quote').evaluateAll(cards => cards.map(c => ({
text: c.querySelector('.text').textContent,
author: c.querySelector('.author').textContent,
})));
console.log(quotes.length);
await browser.close();To write a script faster, let Playwright record one: playwright codegen https://quotes.toscrape.com/js/ (or npx playwright codegen …) opens a browser and turns your clicks into code with suggested locators (playwright.dev: Codegen).
Locators and selectors
A locator is a description of how to find an element, evaluated fresh every time you use it, which is why it survives re-renders. Create one with page.locator() or the get_by_* helpers (playwright.dev: Locators):
| Locator | Finds | Example |
|---|---|---|
| CSS (the default for a string) | Elements by tag, class, attribute | page. |
| XPath | Elements by path; strings starting with // are read as XPath | page. |
| Text | Elements by visible text | page. |
| Role | Elements by accessible role and name | page. |
| Test id | data-testid attributes | page. |
| Filter | Narrow a locator | page. |
For scraping, CSS and XPath on stable attributes (IDs, data-* attributes, itemprop) break least often. Role and text locators are the testing default and work well for buttons and links you click. A locator that matches several elements returns a list through .all(), .all_inner_texts() or .evaluate_all(); calling an action such as .click() on it raises a strict-mode error, which protects you from clicking the wrong element.
Waiting: page loads, waitFor and timeouts
Most waits in Playwright are automatic: click(), fill() and inner_text() wait for the element first. You need an explicit wait when you read data that arrives later.
page.goto(url, wait_until=...)takes"commit","domcontentloaded","load"(the default) or"networkidle". The Playwright docs discouragenetworkidlefor tests, because many pages never go quiet; for scraping,domcontentloadedplus a locator wait is faster and more reliable.locator.wait_for(state="visible")waits until the element is attached and visible; the other states are"attached","detached"and"hidden". This is thewaitFormost scrapers need.page.wait_for_load_state("load")waits for a load state after an action that navigates.page.wait_for_function("() => window.dataReady === true")waits for any condition in the page.page.expect_response(...)waits for a specific network response and gives you its body.
Timeouts are 30 seconds by default for actions and navigation in library scripts. Change them for the whole page with page.set_default_timeout(60_000) and page.set_default_navigation_timeout(60_000), or per call with timeout=. In the Playwright Test runner, each test has a 30-second timeout, expect assertions 5 seconds, and globalTimeout in playwright.config caps the whole run (playwright.dev: Timeouts).
Pagination and following links
Read the "Next" link's address and goto() it until there is none; a URL can be retried and logged, a click cannot.
from urllib.parse import urljoin
url, rows = "https://quotes.toscrape.com/js/", []
while url:
page.goto(url, wait_until="domcontentloaded")
page.locator(".quote").first.wait_for()
rows += page.locator(".quote .text").all_inner_texts()
nxt = page.locator("li.next > a")
url = urljoin(page.url, nxt.get_attribute("href")) if nxt.count() else NoneScraping infinite scroll with Playwright
Scroll, wait until more items exist than before, and stop when nothing new arrives or after a fixed number of rounds.
from playwright.sync_api import TimeoutError as PlaywrightTimeout
page.goto("https://quotes.toscrape.com/scroll", wait_until="domcontentloaded")
page.locator(".quote").first.wait_for()
count = page.locator(".quote").count()
for _ in range(30):
page.mouse.wheel(0, 5000)
try:
page.wait_for_function(
"n => document.querySelectorAll('.quote').length > n", arg=count, timeout=5000)
except PlaywrightTimeout:
break # no new items: end of the feed
count = page.locator(".quote").count()
print(count, "quotes loaded")Each batch of an infinite feed usually comes from a JSON request. Catch it instead of parsing the page: with page.expect_response(lambda r: "/api/" in r.url) as resp: page.mouse.wheel(0, 5000) and then resp.value.json().
Taking screenshots with Playwright
page.screenshot() captures the viewport or, with full_page=True, the whole scrollable page; locator.screenshot() captures one element (playwright.dev: Screenshots).
page.set_viewport_size({"width": 1366, "height": 900})
page.screenshot(path="page.png", full_page=True)
page.locator(".quote").first.screenshot(path="first-quote.png")
page.screenshot(path="clean.png", mask=[page.locator(".cookie-banner")]) # hide an elementSpeed and scale: contexts, async and blocking resources
A browser page is heavy, so scale by reusing what you can:
-
One browser, many contexts
Browser.new_context()is light and isolated; give each job its own context and close it after. -
Async API for concurrency
In Python,
async_playwrightwithasyncio.gather()and a semaphore runs several pages at once in one process. -
Block what you do not read
Images, fonts and media are most of a page's weight and of your proxy traffic:
page.route("**/*", lambda route: route.abort()
if route.request.resource_type in {"image", "font", "media"} else route.continue_())- Use the browser only where needed. Fetch static pages over HTTP and send only the JavaScript-rendered ones to Playwright.
import asyncio
from playwright.async_api import async_playwright
async def title(browser, sem, url):
async with sem:
ctx = await browser.new_context()
page = await ctx.new_page()
await page.goto(url, wait_until="domcontentloaded")
t = await page.title()
await ctx.close()
return t
async def main(urls):
async with async_playwright() as p:
browser = await p.chromium.launch()
sem = asyncio.Semaphore(4) # 4 pages at a time
print(await asyncio.gather(*(title(browser, sem, u) for u in urls)))
await browser.close()
asyncio.run(main(["https://quotes.toscrape.com/js/page/1/", "https://quotes.toscrape.com/js/page/2/"]))Playwright scraping best practices
- Prefer stable selectors (IDs,
data-*attributes, structured data in<script type="application/ld+json">) over long CSS paths. - Wait for the data element, not for
networkidleand not for fixed sleeps. - Set explicit timeouts and catch
TimeoutError; log the URL and save a screenshot on failure. - Close contexts and pages; a leaked context keeps memory and connections.
- Keep sessions consistent: one context, one IP, one set of cookies for one logical visitor.
- Pace requests per site, and collect only public data the site's terms allow; see is web scraping legal.
- When a site blocks you, read how anti-bot systems detect scrapers before adding more IPs.
Using proxies with Playwright
Playwright takes the proxy as an option with the server, username and password as separate fields, for the whole browser or for each context.
browser = p.chromium.launch(proxy={
"server": "http://gate.proxyfield.io:7000",
"username": "<login>-country-us-city-new_york",
"password": "<password>",
})<login>-country-us-city-new_york asks for an IP in New York, United States. 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. One proxy per context, SOCKS5, rotation and verification are in the Playwright proxy setup guide. ProxyField residential proxies cost $2.95/GB for 1 GB down to $1.95/GB from 1,000 GB, with no subscription; the traffic never expires, and all purchases are final (residential and mobile proxy pricing).
Web Scraping With Playwright FAQ
Is Playwright good for web scraping?
ProxyField's residential proxies (412M+ residential IPs, from $1.95/GB) plug into Playwright's proxy option, and Playwright is good for web scraping pages that render their data with JavaScript or need clicks and scrolling. For pages whose data is in the HTML source, an HTTP client is faster and uses less traffic.
When should I use Playwright for scraping?
When the page source does not contain the data and it appears only after scripts run, or when you must click, scroll, fill a form or keep a browser session. If the data comes from a JSON request you can see in DevTools, call that request directly instead.
How do I scrape with Playwright?
Launch a browser, open the page with page.goto(), wait for the element that holds the data with locator.wait_for(), read it with inner_text() or all_inner_texts(), and close the browser. The step-by-step script above does this in Python and Node.js.
How do I wait for a page to load in Playwright?
Pass wait_until to page.goto() (domcontentloaded, load or networkidle), then wait for the element you need with locator.wait_for(). The docs discourage networkidle, because many pages never stop sending requests.
What does waitFor do in Playwright?
locator.waitFor() (wait_for() in Python) waits until the element reaches a state: visible by default, or attached, detached or hidden. It throws a timeout error if that does not happen within the timeout, 30 seconds by default.
How do I set a global timeout in Playwright?
In a library script, call page.set_default_timeout() and page.set_default_navigation_timeout() (or the same on a browser context). In the Playwright Test runner, set timeout for each test and globalTimeout for the whole run in playwright.config.
Which selectors does Playwright support?
CSS by default, XPath (any selector starting with //), and built-in locators by role, text, label, placeholder, alt text, title and test id. You can chain and filter them, for example page.locator(".card").filter(has_text="In stock").
What is page.locator in Playwright?
page.locator(selector) returns a locator: a lazy description of how to find elements, resolved each time you act on it. Actions on a locator wait for the element automatically, and a locator matching several elements gives you a list through .all().
How do I scrape infinite scroll with Playwright?
Scroll with page.mouse.wheel(), wait until more items exist than before with page.wait_for_function(), and stop when no new items arrive or after a fixed number of rounds. Catching the JSON request behind each batch with page.expect_response() is often faster.
How do I take screenshots with Playwright?
Call page.screenshot(path="page.png") for the viewport, add full_page=True for the whole page, or use locator.screenshot() for one element. The mask option hides elements such as cookie banners.
Show 5 more questionsShow fewer questions
How do I write and run a Playwright script?
Install the library and a browser, write the script with sync_playwright() in Python or import { chromium } from 'playwright' in Node.js, and run it with python scrape.py or node scrape.mjs. playwright codegen <url> records your clicks into a starting script.
Is Playwright better than Requests for web scraping?
Only for pages that need a browser. Requests downloads the HTML in milliseconds with almost no memory, but it cannot run JavaScript; Playwright can, at the cost of time, memory and traffic per page, so many scrapers use both.
Is Playwright suitable for large-scale scraping projects?
Yes, if you design for it: one browser with many contexts, a concurrency limit, blocked images and fonts, retries and a queue. Send only the pages that need JavaScript to Playwright and fetch the rest over HTTP.
How do I speed up a Playwright scraper?
Block images, fonts and media with page.route(), wait for selectors instead of network idle, reuse one browser with a context per job, and run pages concurrently with the async API. Skip the browser for any data you can get from a JSON endpoint.
What are Playwright best practices for scraping?
Use stable selectors, wait for elements rather than fixed sleeps, set and handle timeouts, close every context, and keep one session per IP and cookie set. Pace requests per site and collect only public data the site's terms allow.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.