Tutorial8 min read

Web Scraping With Playwright

Short answer

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
  1. Is Playwright good for web scraping?
  2. Install Playwright
  3. Your first Playwright scraper in Python
  4. Locators and selectors
  5. Waiting: page loads, waitFor and timeouts
  6. Pagination and following links
  7. Scraping infinite scroll with Playwright
  8. Taking screenshots with Playwright
  9. Speed and scale: contexts, async and blocking resources
  10. Playwright scraping best practices
  11. Using proxies with Playwright
  12. 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.

SituationUse
Data is in the HTML sourceAn HTTP client and a parser (Requests and Beautiful Soup, or fetch and Cheerio)
Data loads from a JSON endpoint you can see in DevToolsCall the endpoint directly
Data appears only after scripts run, scrolls or clicksPlaywright
Many pages, mixed static and dynamicA 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.

Shell
# Python
pip install playwright
playwright install chromium

# Node.js
npm i playwright
npx playwright install chromium

playwright 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.

Python
# 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:

JavaScript
// 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):

LocatorFindsExample
CSS (the default for a string)Elements by tag, class, attributepage.locator("div.product span.price")
XPathElements by path; strings starting with // are read as XPathpage.locator("//table//tr[2]/td[3]")
TextElements by visible textpage.get_by_text("Add to cart")
RoleElements by accessible role and namepage.get_by_role("button", name="Next")
Test iddata-testid attributespage.get_by_test_id("price")
FilterNarrow a locatorpage.locator(".card").filter(has_text="In stock")

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 discourage networkidle for tests, because many pages never go quiet; for scraping, domcontentloaded plus 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 the waitFor most 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).

Scraping 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.

Python
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).

Python
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 element

Speed and scale: contexts, async and blocking resources

A browser page is heavy, so scale by reusing what you can:

  1. One browser, many contexts

    Browser.new_context() is light and isolated; give each job its own context and close it after.

  2. Async API for concurrency

    In Python, async_playwright with asyncio.gather() and a semaphore runs several pages at once in one process.

  3. Block what you do not read

    Images, fonts and media are most of a page's weight and of your proxy traffic:

Python
page.route("**/*", lambda route: route.abort()
           if route.request.resource_type in {"image", "font", "media"} else route.continue_())
  1. Use the browser only where needed. Fetch static pages over HTTP and send only the JavaScript-rendered ones to Playwright.
Python
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 networkidle and 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.

Python
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.

Easy Trial: 1 GB for $2.95.

Try for the price of 1 GB, no subscription, traffic never expires.