Quick answer: To get the HTML of a page after JavaScript has run, load it in a headless browser and call page.content() in Playwright or Puppeteer. That returns the full DOM as it exists after scripts execute, not the initial HTML the server sent. The key is waiting for the content to actually appear (a selector or network idle) before you read it, otherwise you capture a half-built page.
There are two versions of every modern web page. There is the HTML the server sends, and there is the DOM the browser ends up with after JavaScript runs, fetches data, and mutates the page. For a lot of sites, those two are wildly different, and the version you want is almost always the second one.
curl and requests give you the first. Here is how to get the second.
Initial HTML vs the rendered DOM
When you curl a page, you get the raw response body: what the server sent before any JavaScript touched it. On a static site, that is the whole page. On a React or Vue app, that is an empty shell.
The rendered DOM is what exists after the browser parses the HTML, runs the scripts, applies hydration, fetches data, and updates the page. This is what a user sees. This is what you usually want to scrape.
| Initial HTML | Rendered DOM | |
|---|---|---|
| Source | Server response body | Browser after JS runs |
| Get it with | curl, requests, fetch |
Headless browser page.content() |
| SPA content | Missing | Present |
| Speed | Instant | Slower (must run JS) |
| Reflects user view | Not always | Yes |
If you are not sure which one a site needs, this comparison of curl vs headless vs stealth browser lays out when each is the right tool.
Get the rendered HTML with Playwright
page.content() serializes the current DOM, including everything JavaScript added. This is the standard way.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com", wait_until="networkidle")
rendered_html = page.content()
print(len(rendered_html))
browser.close()
wait_until="networkidle" tells Playwright to wait until there have been no network connections for a short window, which usually means the data has loaded. It is a good default, but not perfect for pages that poll or stream, so pair it with a selector wait when you can.
The same in Puppeteer
Puppeteer (Node) works identically. The method is also page.content().
import puppeteer from "puppeteer";
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto("https://example.com", { waitUntil: "networkidle0" });
const renderedHtml = await page.content();
console.log(renderedHtml.length);
await browser.close();
networkidle0 waits for zero network connections; networkidle2 allows up to two. Use networkidle0 for pages that fully settle, networkidle2 for pages with long-lived connections like analytics beacons.
Wait for the content, not a timer
The number one reason page.content() returns incomplete HTML is reading it too early. The page loaded, but the data-fetching call had not finished. Do not fix this with a sleep. Wait for the thing you actually need.
page.goto("https://example.com")
# Block until the real content is in the DOM
page.wait_for_selector("article.post-body")
rendered_html = page.content()
Waiting for a specific selector is the most reliable strategy because it finishes the moment the content exists and fails fast if it never does. A blind sleep(5) is a guess, and guesses break in production.
page.content() vs outerHTML: what is the difference?
People mix these up. They return similar-looking strings but they are not the same.
page.content()returns the full serialized document, including the doctype and the<html>root. It is a complete HTML document.document.documentElement.outerHTML(run viapage.evaluate) returns the<html>element and its children, without the doctype.element.outerHTMLon a specific node returns just that node's HTML, which is what you want when you only need one section.
// Full document
const doc = await page.content();
// Just one element's rendered HTML
const cardHtml = await page.$eval(".product-card", (el) => el.outerHTML);
For most scraping, page.content() is what you want: the whole rendered page. Reach for outerHTML on an element when you want to isolate a fragment.
What about JavaScript that never finishes?
Some pages load content on scroll, on a timer, or only after interaction. networkidle will not save you there because the page is technically idle while still incomplete. For those cases you scroll, click, or wait for a specific response before calling content(). If your target needs a click or a popup dismissed first, see our guide on clicking and interacting before scraping.
The one-call API alternative
Running a headless browser to grab rendered HTML is a lot of moving parts: launching Chromium, managing memory, waiting correctly, and handling crashes. If you just want the rendered result, an API does all of that in a single request.
curl https://link.sc/v1/fetch \
-H "Authorization: Bearer lsc_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "format": "html"}'
link.sc renders the page in a real browser and returns the result. Ask for "format": "html" to get the rendered HTML, or "format": "markdown" to get clean, parsed content ready for an LLM. You skip the browser fleet entirely. The fetch docs list the formats and options.
Quick decision guide
| You want | Do this |
|---|---|
| Rendered HTML of a whole page | page.content() after a selector wait |
| Just one section | element.outerHTML |
| Rendered HTML without running a browser | A rendering API |
| Only the initial server HTML | curl or requests, no browser needed |
An ethics note
Render and read public pages, respect robots.txt, and do not use a headless browser to slip past a login or paywall. Add delays between requests so you are polite to the origin server. Rendering is expensive on both ends, so cache what you fetch instead of re-rendering the same URL repeatedly.
Bottom line
Getting rendered HTML comes down to two things: run the page in a real (headless) browser, and wait for the content before you read it. page.content() gives you the full post-JavaScript DOM in both Playwright and Puppeteer. If you would rather not operate the browser yourself, a fetch API returns the same rendered output in one call.
Get fully rendered HTML from any page in one request, no browser to maintain. Try link.sc free.