Obscura Reliability — Timeouts, Resource Boundaries, and Production Tuning

Introduction to Obscura's reliability design: layered timeouts, V8 termination watchdog, DOM operation safety, SSRF protection, and timeout tuning in production.

16Yun Engineering TeamJul 8, 202611 min read

One Bad Page Must Not Wedge the Service

Once you run Obscura in production, you will eventually feed it difficult pages: a script with an infinite loop, an API that never responds, a deeply nested DOM, or a request aimed at an internal address. A reliable browser engine must recover gracefully from these, not let a single bad page freeze a worker and starve every other session running on it.

This article introduces the reliability mechanisms Obscura provides for exactly that, with the focus on what you can actually configure and tune: the timeout system and the SSRF boundary. The goal is to help you dial the timeouts to the right values in production — slow enough not to kill legitimate heavy pages, tight enough not to let a slow page erode throughput.

Layered Timeout System

Obscura's timeout is not a single knob. It is four independent boundaries, each guarding a different scope. This is the skeleton for understanding reliability configuration.

LayerVariableDefaultScopeNotes
NavigationOBSCURA_NAV_TIMEOUT_MS30sPage.navigate + CLI fetchHard limit per navigation
CDP commandOBSCURA_CDP_COMMAND_TIMEOUT_MS60sAll CDP commandsPrevents one session from holding V8; set 0 to disable
Fetch/XHROBSCURA_FETCH_TIMEOUT_MS30sJS fetch/XHR/modulePrevents a silent server from hanging XHR
Process (fetch only)Programmatictimeout+wait+10sfetch subcommandFinal backstop

All four are configured via environment variables:

OBSCURA_NAV_TIMEOUT_MS=60000 \
OBSCURA_CDP_COMMAND_TIMEOUT_MS=30000 \
OBSCURA_FETCH_TIMEOUT_MS=20000 \
  obscura serve

OBSCURA_NAV_TIMEOUT_MS is the hard ceiling on a single navigation (default 30 seconds), applied to both Page.navigate and the CLI fetch command. If your targets are slow or you scrape heavy SPAs, raise it (60s or more); for mostly-static pages, 30s is usually plenty.

CDP Command Timeout

OBSCURA_CDP_COMMAND_TIMEOUT_MS is the V8 execution budget per CDP command (default 60 seconds). Its core purpose is to stop a stuck page — say a runaway Runtime.evaluate — from holding the shared V8 lock indefinitely and freezing every other session on the worker. Set it to 0 to disable.

This matters because all pages share one V8 Isolate and JS execution serializes through a single global lock. A command with no ceiling can freeze an entire worker.

Fetch Timeout

OBSCURA_FETCH_TIMEOUT_MS bounds scripted fetch(), XMLHttpRequest, and ES module loads (default 30 seconds). It covers the case where a server accepts the connection but never responds, leaving XHR waiting forever for a completion event that never comes. Without this timeout, a single slow endpoint can hang page scripts indefinitely.

Process Hard Deadline

The fetch subcommand additionally runs a process-level daemon thread that force-exits after timeout + wait + 10 seconds. This is the absolute backstop: when every inner timeout has failed (for example, a synchronous Rust op is stuck and even the V8 watchdog cannot interrupt it), the hard deadline guarantees a fetch eventually returns.

Multi-Worker Isolation: Contain the Blast Radius

The four timeouts above answer "how does a single page get terminated". There is one more reliability lever above them: multi-process isolation. obscura serve --workers N runs multiple worker processes, each with its own V8 Isolate, which means:

  • A bad page on one worker (heavy JS that saturates the V8 lock, for example) only slows that one worker. Sessions on other workers are unaffected.
  • If a worker crashes in an extreme case, the main process's load balancer keeps routing traffic to the surviving workers.
  • Sessions are sticky to a worker — every request for one browser context lands on the same worker — so isolation does not break session semantics.

This layer complements the timeout system: timeouts handle "stuck but recoverable", multi-process isolation handles "totally broken". In production you want both — timeouts dialed to fit, and the worker count set to your CPU core count.

V8 Termination Watchdog

Ordinary async timeouts (like tokio::time::timeout) can only cancel tasks that are suspended at an await point. But a JavaScript infinite loop is synchronous — it never hits an await, so async timeouts cannot touch it.

Obscura's V8 termination watchdog exists precisely for this kind of synchronous blocking: it counts down on a separate thread and, on expiry, terminates V8 execution directly from that thread. The mechanism covers --eval execution, post-load JavaScript microtask storms, and navigation event-loop pumps. A separate CDP-layer watchdog times every CDP command (the one governed by OBSCURA_CDP_COMMAND_TIMEOUT_MS), so a runaway command cannot hold the V8 lock without limit.

As a user, you do not operate the watchdog directly. What you need to know is that synchronous infinite loops, runaway recursion, and microtask storms — the things that "freeze V8 without ever hitting an await" — all have a backstop, and none of them can permanently freeze a worker.

DOM Operation Safety

DOM operations from the page are bridged through Rust ops. Obscura wraps these ops with safety guards:

  • Panics do not propagate into V8. A Rust panic inside a DOM op is caught and degraded to a null return, instead of unwinding across V8's FFI boundary and aborting the process. For this to work, the release profile is pinned to panic = "unwind".
  • Cycle guards. append_child / insert_before reject inserting an ancestor as a child, so the DOM tree can never contain a cycle.
  • Traversal length cap. As an extra safeguard, traversals like descendants() carry a hard-coded length cap.

These are internal engine safeguards you do not configure, but knowing they exist explains a behavior you may see occasionally: a DOM operation silently returns null instead of throwing. That is usually a panic caught by the safety layer and degraded — not a logic error on your side.

SSRF Protection

Obscura blocks pages from reaching internal network addresses by default, so a malicious page cannot turn your worker into an internal-network scanner. The blocked ranges include:

  • Loopback: 127.0.0.0/8, ::1
  • RFC1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • Link-local: 169.254.0.0/16 (includes 169.254.169.254)
  • IPv6 unique-local: fc00::/7
  • Unspecified: 0.0.0.0, ::
  • All IPv4-mapped forms of the above

Validation runs twice: at URL parse time on the host, and again after DNS resolution on the resolved address (DNS rebinding protection). Even a public hostname that resolves to an internal IP is rejected at connect time.

For local testing that needs to reach an internal address, opt out with the global flag:

obscura fetch http://localhost:8080 --allow-private-network
# or
OBSCURA_ALLOW_PRIVATE_NETWORK=1 obscura serve

Do not enable --allow-private-network in production lightly. Unless you fully trust the pages you are scraping, disabling SSRF protection exposes the worker's entire reachable network range to page scripts.

Common Symptoms and Responses

Combining the reliability configuration with the diagnostic tools above, most anomalies you hit in production fall into a few categories:

SymptomTypical causeResponse
A single session's goto never returnsSlow target, or sustained requests break networkidleRaise OBSCURA_NAV_TIMEOUT_MS, or switch to a domcontentloaded wait strategy
Every session on a worker slows downOne heavy-JS page is holding the V8 lockLower OBSCURA_CDP_COMMAND_TIMEOUT_MS, or enable multi-worker isolation
RSS climbs as scrape volume growsV8 heap too large, or cookie/localStorage accumulatingLower --v8-flags "--max-old-space-size", periodically clean --storage-dir
A page-script fetch never fires its callbackTarget API accepts the connection but never respondsConfirm OBSCURA_FETCH_TIMEOUT_MS is set (default 30s)
Requests to internal addresses are refusedSSRF protection is activeOnly use --allow-private-network in trusted scenarios; never default it on in production
Occasional DOM operations return nullA panic was caught and degraded by the safety layerCheck the target page for anomalous DOM structure; this is safe degradation, not a logic error

The value of this table is mapping each symptom directly to the dial you should turn, so you spend less time digging through docs mid-incident.

Monitoring and Alerting

Reliability configuration is not a one-time setup; it needs ongoing observation. Metrics worth instrumenting:

  • Session success rate: completed Page.navigate as a share of total. Alert below a threshold (say 95%) — usually signals a target-site change or proxy quality issue.
  • Average and P95 navigation latency: reflects overall target-site health. A sudden spike often means the target added anti-scraping or the proxy slowed down.
  • Timeout trigger counts: one counter per timeout layer. Whichever layer fires most often points at where the problem lives.
  • Worker RSS trend: resident memory per worker. Monotonic climb without drop is a leak signal.
  • Live worker count: in multi-worker mode, fewer live workers than configured means a process crashed — alert and investigate.

These metrics can be extracted from obscura serve --verbose logs, or measured at the reverse proxy layer (Nginx/Caddy) via WebSocket connection counts and request durations. Set alert thresholds "outside the normal range of variation" rather than at absolute values to reduce false positives — for example, a small success-rate dip on a low-traffic weekend should not page anyone, but a weekday drop from 99% to 80% definitely should.

Diagnosing Stuck Sessions

Once reliability is configured, the next question is "why is it slow". A few diagnostic approaches:

Read the lifecycle event chain. Under obscura serve --verbose, the logs print each session's lifecycle events (init → commit → domcontentloaded → load → networkidle2 → networkidle0). If a session sits at load and never reaches networkidle0, something — long polling or sustained requests — keeps breaking the idle condition. The fix then is the waitUntil strategy or --wait, not the timeout.

Separate slow navigation, slow fetch, slow JS. Three kinds of slowness map to three different knobs: slow navigation → OBSCURA_NAV_TIMEOUT_MS; slow scripted fetch → OBSCURA_FETCH_TIMEOUT_MS; slow JS execution (typically surfacing as a CDP command timeout) → OBSCURA_CDP_COMMAND_TIMEOUT_MS, or look at whether the page's own scripts are simply too heavy.

Use finer log levels to pinpoint. RUST_LOG=obscura=debug for engine internals, RUST_LOG=obscura_cdp=trace for CDP message flow, RUST_LOG=obscura_net=debug for the network layer. When something hangs, the last log line before the hang is usually the exact spot.

Watch RSS rather than per-request latency. Reliability is not only "does not hang" but also "does not leak". Use docker stats or systemd memory accounting to track worker RSS over time. If RSS climbs monotonically with scrape volume and never drops back, either the V8 heap is set too high, or cookies/localStorage are accumulating — the latter can be relieved with --storage-dir plus periodic cleanup.

Production Tuning Experience

Putting all of this into production, a few practical lessons are worth keeping:

1. Tune timeouts to your page mix. For static news sites, a 20s navigation timeout and a 15s fetch timeout are enough and keep the cost of slow pages low. For heavy SPAs (e-commerce, social), raise navigation to 60s and fetch to 30s to avoid killing legitimate loads. When one worker serves multiple page types, pick the ceiling of the slowest type.

2. Do not disable the CDP command timeout casually. OBSCURA_CDP_COMMAND_TIMEOUT_MS=0 sounds like "no limit", but the cost is that a runaway Runtime.evaluate can freeze every session on the worker. Unless you fully trust the driving scripts, keep the default 60s or tighten to 30s.

3. Tune worker count alongside timeouts. Concurrent pages within a single worker contend for the V8 lock, so a slow page drags fast ones. Use --workers N (N = CPU cores) to run multiple processes, so a slow page only blocks its own worker.

4. Use --verbose to locate stuck pages. When throughput drops without an obvious cause, the RUST_LOG=obscura=info obscura serve logs show which session is waiting for which lifecycleEvent, helping you tell quickly whether the bottleneck is navigation, fetch, or JS execution.

5. The process hard deadline only exists on fetch. A long-running serve does not carry this backstop, because it should not exit over a single fetch. So under serve, "hang" protection relies entirely on the three inner timeouts — make sure they are configured properly.

Summary

Obscura's reliability design can be summarized as "a boundary at every layer": navigation has a timeout, CDP commands have a timeout, scripted fetch has a timeout, synchronous infinite loops have the V8 watchdog, DOM operations have panic safety and cycle guards, network requests have dual SSRF validation, and the fetch command has a process hard deadline. You do not operate these mechanisms directly, but you do need to dial the timeouts to suit your production page mix. Together they ensure that under most abnormal conditions Obscura neither crashes nor hangs permanently, and one bad page cannot take down the whole service.

Need an enterprise proxy plan?

We can tailor architecture to your target domains, concurrency, and reliability goals.