Why Your Headless Browser Keeps Breaking on JS-Heavy Sites
A deep look at why JavaScript-rendered pages trip up most scrapers, and how to capture them reliably with the right API setup.
Modern web apps push more rendering to the client every year. React, Vue, Svelte, HTMX, and a long tail of SPA frameworks mean that a raw HTTP request to a URL almost never returns the content a user actually sees. If you've ever tried to screenshot a dashboard, generate an OG image from a Next.js page, or archive a single-page app and ended up with a blank canvas or a loading spinner, you've hit the JavaScript rendering wall.
This post breaks down what actually goes wrong when you try to capture JavaScript rendered pages, and how to build (or buy) an API that handles them correctly.
What "JavaScript rendered" actually means
A page is JS-rendered when the meaningful DOM content is constructed after the initial HTML loads. The server might return a near-empty <div id="root"></div>, and everything else — text, images, layout — gets built by JavaScript fetching data and mutating the DOM.
Common patterns that require JS execution to capture properly:
- Client-side routed apps (React Router, Vue Router)
- Pages that fetch data from
/apiendpoints after mount - Lazy-loaded images using
IntersectionObserver - Charts rendered with D3, Chart.js, or Recharts
- Auth-gated content behind cookies or tokens
- Animations and fonts that affect final layout
Why a naive screenshot pipeline fails
If you spin up Puppeteer or Playwright yourself, your first version probably looks like this:
await page.goto(url);
await page.screenshot({ path: 'out.png' });This works for static sites and breaks everywhere else. Here's what tends to go wrong:
1. You screenshot before the app finishes loading
page.goto with the default load event fires when the initial document and subresources finish — not when your React app has hydrated and rendered. You need networkidle0 or networkidle2, plus often an explicit wait for a selector like await page.waitForSelector('[data-loaded]').
2. Lazy-loaded content never triggers
If the page uses IntersectionObserver to load images as you scroll, a viewport screenshot will miss most of them. You need to scroll the page programmatically and wait between steps:
await page.evaluate(async () => {
await new Promise((resolve) => {
let total = 0;
const timer = setInterval(() => {
window.scrollBy(0, 200);
total += 200;
if (total >= document.body.scrollHeight) {
clearInterval(timer);
resolve();
}
}, 100);
});
});3. Fonts cause layout shift after the screenshot
Web fonts often load asynchronously. If you capture before document.fonts.ready resolves, text reflows after the fact. Always await it:
await page.evaluate(() => document.fonts.ready);4. You hit memory limits at scale
Chromium processes are heavy. Each instance eats 200–500MB. Running ten concurrent screenshots on a small VPS will OOM quickly, and a Lambda function gives you about 250MB of binary headroom before the package limit becomes painful.
What a good capture API needs to do
Whether you're building this in-house or evaluating a service, the checklist for capturing JavaScript rendered pages is roughly:
- Real browser execution — Chromium or Firefox, not an HTML parser
- Configurable wait strategies — network idle, selector-based, fixed delay, or custom JS condition
- Full-page support with auto-scrolling for lazy content
- Custom viewport and device emulation for responsive captures
- Header and cookie injection for authenticated pages
- Multiple output formats — PNG for transparency, JPEG for size, WebP for both, PDF for documents
- Element-level capture — screenshot a specific selector rather than the whole page
- Sensible defaults so the simple case stays a one-liner
A working example with PxShot
Rather than maintain a Chromium fleet, most teams now hit a screenshot API. Here's a request against PxShot that captures a JS-rendered dashboard, waits for a specific element, and returns WebP:
GET https://api.pxshot.dev/v1/screenshot
?url=https://app.example.com/dashboard
&format=webp
&full_page=true
&wait_for=%23chart-loaded
&viewport_width=1440
&viewport_height=900The wait_for parameter holds the capture until the selector #chart-loaded exists in the DOM, which is the cleanest way to handle async data fetches. For pages where you can't add a marker, fall back to a fixed delay or network idle.
Generating dynamic OG images
A common use case: your marketing site renders custom Open Graph cards at /og/[slug] using a React component. You want each blog post to have its own preview image without running Satori or a separate image pipeline.
const ogUrl = `https://api.pxshot.dev/v1/screenshot`
+ `?url=https://yoursite.com/og/${slug}`
+ `&viewport_width=1200&viewport_height=630`
+ `&format=png`;
// Use directly in your meta tag
<meta property="og:image" content={ogUrl} />Cache the result on your CDN and you've turned a React route into a social card generator with no extra infrastructure.
Capturing authenticated pages
For pages behind a login, pass cookies or auth headers through the API. With PxShot you can attach a session cookie so the request hits the page as a signed-in user, then capture the rendered state. This is what makes visual monitoring of customer dashboards actually feasible.
Common gotchas to plan for
- CORS-blocked fonts render as fallback faces — preload critical fonts
- Analytics scripts can delay
networkidleindefinitely; consider blocking them at the request level - Cookie consent banners will appear in every screenshot unless you dismiss them or set a cookie pre-load
- Date-dependent content changes between captures — freeze
Date.now()via injected JS if you need visual diffs - Infinite scroll needs a scroll cap or you'll capture pages thousands of pixels tall
Build vs. buy, briefly
Running your own Chromium fleet makes sense if screenshots are core to your product and you have specific compliance needs. For everything else — OG images, link previews, PDF exports, visual regression checks — the maintenance burden almost always outweighs the cost of an API call. Chrome ships breaking changes every six weeks, and so do the headless wrapper libraries.
If you want to stop babysitting browser processes, PxShot has a free tier at pxshot.dev that covers most side projects and lets you test the wait strategies above without spinning up any infrastructure.