Quick answer: Playwright is a browser automation library that drives a real Chromium, Firefox, or WebKit engine, which makes it the right tool for scraping JavaScript-heavy pages that a plain HTTP request returns empty. Install it with pip install playwright then playwright install, launch a browser, navigate to the URL, wait for the content you care about with page.wait_for_selector, and read the data off the rendered DOM. For pages that render server-side or when you just want clean markdown back, a fetch API is faster and cheaper than running a browser.
I reach for Playwright when a page only makes sense after its JavaScript runs. If you curl a modern React or Vue app you often get a near-empty shell with a <div id="root"> and nothing inside it. Playwright loads that page in a real engine, runs the scripts, and hands you the DOM a human would see.
This guide is the practical version: install it, drive it, wait correctly, extract cleanly, and know when to stop using it.
Install Playwright
Playwright ships browser binaries separately from the package, so installation is two steps.
pip install playwright
playwright install chromium
The second command downloads the browser engine. You can install all three engines with a bare playwright install, but Chromium alone covers most scraping.
Node users get the same tool:
npm install playwright
npx playwright install chromium
I will use Python for the examples. The API is nearly identical across languages.
Launch a Browser and Navigate
Here is the smallest useful script. It launches Chromium, opens a page, and prints the title.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://example.com", wait_until="domcontentloaded")
print(page.title())
browser.close()
A few things worth knowing right away:
headless=Trueruns with no visible window. Set it toFalsewhile developing so you can watch what the browser actually does. That single change solves half of all scraping confusion.wait_untilcontrols whengotoreturns."domcontentloaded"fires early,"load"waits for images and stylesheets, and"networkidle"waits until the network goes quiet. For dynamic pages I usually pair a fastwait_untilwith an explicit wait for a specific element (next section).
Wait Strategies (The Part Everyone Gets Wrong)
The number one Playwright mistake is extracting data before the page has rendered it. The fix is never time.sleep. Wait for a condition instead.
# Wait for a specific element to exist in the DOM
page.wait_for_selector("div.product-card")
# Wait for the network to settle (good after triggering a fetch)
page.wait_for_load_state("networkidle")
# Wait for a custom condition
page.wait_for_function("document.querySelectorAll('.product-card').length > 10")
Playwright also has auto-waiting built into its actions. When you call page.click("button.load-more"), it waits for that button to be visible and actionable first. You get a lot of reliability for free, which is why I prefer it over older tools.
Here is the difference in practice:
| Approach | Reliability | Speed | Notes |
|---|---|---|---|
time.sleep(5) |
Poor | Slow | Guesses; too short breaks, too long wastes time |
wait_for_selector |
High | Fast | Returns the moment the element appears |
wait_for_load_state("networkidle") |
Medium | Medium | Good for XHR-driven content; can hang on sites that poll |
wait_for_function |
High | Fast | Best for "wait until N items loaded" |
Extract Data From the Rendered Page
Once the content is there, you read it with locators. A locator is a lazy, re-queryable handle to elements.
# Single value
price = page.locator("span.price").inner_text()
# All matches
titles = page.locator("h2.title").all_inner_texts()
# An attribute
href = page.locator("a.detail-link").first.get_attribute("href")
For structured extraction across many cards, loop over the locator count:
cards = page.locator("div.product-card")
results = []
for i in range(cards.count()):
card = cards.nth(i)
results.append({
"title": card.locator("h2").inner_text(),
"price": card.locator(".price").inner_text(),
"url": card.locator("a").get_attribute("href"),
})
If you would rather run one big query in the browser, page.evaluate drops you into JavaScript and returns plain data:
data = page.evaluate("""
() => Array.from(document.querySelectorAll('.product-card')).map(c => ({
title: c.querySelector('h2')?.innerText,
price: c.querySelector('.price')?.innerText,
}))
""")
Handle Dynamic Content and Interactions
Real pages need clicks, scrolls, and form input before the data shows up. Playwright handles all of it, and it waits for each action to be safe.
# Click a "load more" button until it disappears
while page.locator("button.load-more").count() > 0:
page.click("button.load-more")
page.wait_for_load_state("networkidle")
# Fill and submit a search
page.fill("input[name='q']", "wireless headphones")
page.press("input[name='q']", "Enter")
page.wait_for_selector(".search-result")
For a deeper treatment of pre-scrape interactions, I wrote a separate walkthrough on how to click and interact before scraping.
Take Screenshots
Screenshots are useful for debugging and for capturing pages as evidence.
page.screenshot(path="page.png") # visible viewport
page.screenshot(path="full.png", full_page=True) # entire scrollable page
page.locator(".chart").screenshot(path="chart.png") # one element
When a scraper "works on my machine" but returns nothing in CI, a full-page screenshot is the fastest way to see what the headless browser actually loaded (often a cookie wall or a bot check).
A Complete Runnable Example
This scrapes a paginated product listing, following the "next" link until it runs out, with a hard page cap so it can never loop forever.
from playwright.sync_api import sync_playwright
import json, time
def scrape(start_url, max_pages=10):
results = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(start_url, wait_until="domcontentloaded")
for _ in range(max_pages):
page.wait_for_selector("div.product-card")
cards = page.locator("div.product-card")
for i in range(cards.count()):
card = cards.nth(i)
results.append({
"title": card.locator("h2").inner_text(),
"price": card.locator(".price").inner_text(),
})
next_link = page.locator("a[rel='next']")
if next_link.count() == 0:
break
next_link.first.click()
page.wait_for_load_state("networkidle")
time.sleep(1) # be polite between pages
browser.close()
return results
if __name__ == "__main__":
data = scrape("https://example.com/products")
print(json.dumps(data, indent=2))
print(f"scraped {len(data)} items")
Swap the selectors and the start URL for your target. The structure (wait, extract, find next, repeat, cap) is the same for most listings.
When to Use Playwright vs a Fetch API
Playwright is powerful, but a full browser per page is heavy. You pay in memory, CPU, and maintenance: every browser upgrade, every proxy rotation, every bot check becomes your problem. That cost is worth it when you genuinely need interaction or engine-level rendering.
When you just need the content of a page as clean text, a fetch API does the rendering for you and returns markdown or JSON in one HTTP call. link.sc handles the browser, proxies, and parsing server-side:
curl https://link.sc/v1/fetch \
-H "Authorization: Bearer lsc_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/products", "format": "markdown"}'
That is one request instead of a browser fleet. See the link.sc docs for the full parameter set.
My rule of thumb:
| Situation | Use |
|---|---|
| Multi-step interaction, logins, complex clicks | Playwright |
| JS-rendered page, you want clean text back | Fetch API |
| Static HTML you can parse yourself | requests + a parser |
| Scale across thousands of URLs without babysitting browsers | Fetch API |
For a broader comparison of the three tiers, see the wider guide to scraping JavaScript-rendered websites.
A Note on Ethics
Scrape public data, respect robots.txt, keep your request rate reasonable, and do not use automation to get past login walls you are not authorized to cross. Adding a short delay between pages, as in the example above, is both polite and less likely to get you blocked.
Wrapping Up
Playwright earns its place when a page needs a real browser: install it, launch headfully while developing, wait for conditions instead of sleeping, and extract with locators. When you only need the rendered content and not the interaction, skip the browser fleet and let a fetch API do the heavy lifting.
Tired of maintaining headless browsers just to read a page? link.sc renders any URL and returns clean markdown or JSON in one call. Start free with 500 credits a month.