Short answer: JavaScript-rendered pages send an almost-empty HTML shell that the browser fills in by executing scripts and fetching JSON. A plain HTTP scraper sees the shell and gives up. To scrape the real content you need a headless browser that runs the scripts, waits for the DOM to settle, then serialises the final HTML.
Single-page applications (React, Vue, Svelte, Next.js client routes) ship a minimal HTML document — often just a root div and a bundle script. The visible text only appears after the JavaScript runs in a browser. A request library like curl or Python requests never executes that script, so it captures the empty shell. The same failure mode happens with infinite-scroll listings, modal-loaded detail panes, and client-side filters.
A correct JS-aware scraper does this:
networkidle, a DOM selector, or a fixed millisecond budget.document.documentElement.outerHTML as the rendered HTML.The cost is RAM and CPU per page. A full Chromium instance is typically several hundred MB resident — this is why headless-browser-first scrapers are expensive at scale.
fastCRW auto-detects whether a page needs a browser. Static pages go through the HTTP fast path; pages that require JavaScript route to a headless renderer. Instead of full Chromium, the default browser path is LightPanda, a Rust-native headless browser, keeping the whole binary at ~50 MB RAM idle. Full Chromium is still available when a page needs it. Set "renderer": "lightpanda" or "chromium" on POST /v1/scrape — see the scrape endpoint reference for the request body. waitFor takes a millisecond budget for browser renderers.
curl -s URL | grep, the page is client-rendered and needs a headless browser.