JS Rendering
crw supports JavaScript rendering for single-page applications (SPAs) and JS-heavy sites via the Chrome DevTools Protocol (CDP).
Rendering Modes
Set the rendering mode in your config:
[renderer]
mode = "auto" # auto | lightpanda | playwright | chrome | none
page_timeout_ms = 30000
pool_size = 4
| Mode | Behavior |
|---|---|
auto |
Use HTTP first, detect SPAs, fall back to CDP if needed |
lightpanda |
Always use LightPanda for JS rendering |
playwright |
Always use Playwright for JS rendering |
chrome |
Always use Chrome for JS rendering |
none |
HTTP only, never render JS |
Per-request control
Override the rendering mode per request using renderJs:
{
"url": "https://spa-app.com",
"renderJs": true,
"waitFor": 3000
}
| Value | Behavior |
|---|---|
null (default) |
Auto-detect based on heuristics, or fall back to the global render_js_default if set |
true |
Force CDP rendering |
false |
HTTP only |
Global default
To force JS rendering for every request that doesn't specify renderJs explicitly, set render_js_default in your config:
[renderer]
mode = "chrome"
render_js_default = true # alias: force_js = true
# Or via environment variables
CRW_RENDERER__MODE=chrome
CRW_RENDERER__RENDER_JS_DEFAULT=true
# Backward-compat alias:
CRW_RENDERER__FORCE_JS=true
Precedence: a per-request renderJs always wins over the global default. Unset (null) on the request falls back to the default; if the default is also unset, the auto-detection heuristics below apply.
Per-request renderer override
When mode = "auto" and you have multiple renderers configured (e.g., LightPanda + Chrome), the auto-detect chain decides which one to use. Sometimes you already know that a specific site needs Chrome (Cloudflare-protected SPAs) or LightPanda (fast static-JS sites). Pin the renderer per request with the renderer field:
{
"url": "https://x.com/elonmusk",
"renderer": "chrome"
}
| Value | Behavior |
|---|---|
omitted / auto |
Use the configured fallback chain (existing behavior) |
lightpanda |
Hard-pin to LightPanda — no fallback |
chrome |
Hard-pin to Chrome — no fallback |
playwright |
Hard-pin to Playwright — no fallback |
Pinned implies JS
A non-auto renderer value implies renderJs:true. If you set renderJs:false explicitly, the request stays HTTP-only and the pin is silently ignored — renderJs:false always wins. This means the availability check is also skipped when renderJs:false is set, so combinations like {"mode":"none","renderJs":false,"renderer":"chrome"} are accepted.
Errors and validation
If the named renderer isn't available in the server's pool, the request returns HTTP 400 immediately with errorCode: "invalid_request" and a message listing the configured renderers:
{
"success": false,
"error": "renderer 'chrome' not available; configured renderers: [lightpanda]. Update server config or omit the 'renderer' field.",
"errorCode": "invalid_request"
}
For /v1/crawl, this validation runs once at job acceptance — bad combinations return 400 before the job is queued.
Pinning reduces resilience
Hard-pinning a renderer means transient failures of that renderer surface as errors instead of silently falling back to HTTP. If you need maximum resilience, omit renderer (or set it to auto) so the auto-detect chain can fall back. If you need determinism — "I know this site needs Chrome and a LightPanda success would be a wrong answer" — pin it.
For crawls, per-page failures of a pinned renderer are still logged and skipped; the rest of the crawl continues. Pages that fail will not appear in the results.
Per-request country (residential proxy tier)
When the operator configures a chrome_proxy tier backed by a residential proxy provider (e.g. DataImpulse), each request can route through a specific country by setting country in the request body:
{
"url": "https://geo-restricted.example.com",
"country": "us"
}
| Value | Behavior |
|---|---|
| omitted | Uses CRW_RENDERER__PROXY_DEFAULT_COUNTRY if configured, otherwise the provider's global pool |
2-letter code (us, gb, de, fr, jp, …) |
Lowercased and appended to the base proxy username as a country selector |
| Invalid (non-alpha, wrong length) | Silently ignored — falls back to the default country, then the global pool |
The country selector is composed at request time and supplied to Chrome via the CDP Fetch.authRequired event — no per-country container restart needed, and concurrent requests can target different countries without cross-contamination.
This field has no effect when the chrome_proxy tier is not configured. It is a renderer-tier routing hint, not a content filter — the engine does not translate or localize the response.
See Configuration — Residential proxy tier for the operator-side env vars (CRW_RENDERER__PROXY_BASE_USER, CRW_RENDERER__PROXY_BASE_PASS, CRW_RENDERER__PROXY_DEFAULT_COUNTRY).
When To Turn It On
Enable JS rendering when the page content is not present in the initial HTML response. Typical examples:
- single-page applications,
- pages that fetch content after hydration,
- and sites where the meaningful body is assembled client-side.
Do not enable it blindly for every request. HTTP-only fetches are faster and cheaper.
Choosing a waitFor Value
Start with the smallest value that works:
500to1000for lightly hydrated pages,2000for typical JS-heavy pages,3000to5000only when you have confirmed the target hydrates slowly.
:::warning Long waits are not automatically safer. They increase latency and can hide the fact that the page is blocked rather than merely slow. :::
Auto-detection
When renderJs is null, crw fetches the page via HTTP first, then checks for SPA signals:
Triggered when body text < 200 chars and:
- Contains
id="root",id="app",id="__next",id="__nuxt",id="__gatsby",id="svelte" - Contains
ng-app,data-reactroot - Contains
window.__initial_state__,__next_data__,window.__remixcontext,window.__astro
Triggered when:
<noscript>contains "enable javascript"- Body text < 500 chars and URL matches Framer, Webflow, Wix, or Squarespace domains
CDP Backends
LightPanda (Recommended)
Fastest option. Lightweight browser engine purpose-built for scraping.
# Auto-install
crw-server setup
# Manual start
lightpanda serve --host 127.0.0.1 --port 9222 &
[renderer.lightpanda]
ws_url = "ws://127.0.0.1:9222/"
Playwright
[renderer.playwright]
ws_url = "ws://playwright:9222"
Chrome / Chromium
[renderer.chrome]
ws_url = "ws://chrome:9222"
How CDP rendering works
- Open a new browser tab via
Target.createTarget - Navigate to the URL
- Attach to the target via
Target.attachToTarget - Wait for the configured time (
waitForor default 2000ms) - Execute
Runtime.evaluate("document.documentElement.outerHTML")to get rendered HTML - Close the tab via
Target.closeTarget
Cloud vs Self-hosted
- Cloud: JS rendering is always available. The managed infrastructure runs a LightPanda sidecar alongside the engine. Available on fastcrw.com (cloud).
- Self-hosted: You must run
crw-server setupor configure a CDP browser (LightPanda, Chrome, or Playwright) in yourconfig.tomlunder[renderer]. If no JS renderer is configured, requests withrenderJs: truewill fall back to HTTP-only fetching and include a warning.
What To Inspect in the Response
When rendered output looks wrong, check:
metadata.renderedWithto verify a browser was actually used,metadata.elapsedMsto understand the cost of the request,- and
warningto catch anti-bot or fallback situations.
Troubleshooting
- Empty content from JS-heavy sites: Increase
waitFor(e.g.,3000-5000). Some SPAs need extra time to hydrate. renderedWith: "http_only_fallback"in metadata: JS rendering was requested but no renderer is available. Check your deployment configuration.- Internal error on
renderJs: true: Verify the LightPanda sidecar is running and reachable. Check/healthfor renderer status. - Still poor output after increasing
waitFor: The issue may be anti-bot protection or authentication flow, not rendering delay.
Docker Compose
The included docker-compose.yml runs crw with a LightPanda sidecar:
services:
crw:
image: ghcr.io/us/crw:latest
ports:
- "3000:3000"
environment:
- CRW_RENDERER__LIGHTPANDA__WS_URL=ws://lightpanda:9222
lightpanda:
image: lightpanda/lightpanda:latest
command: ["serve", "--host", "0.0.0.0", "--port", "9222"]