SnackOnAI Engineering | Senior AI Systems Researcher | Technical Deep Dive | September 12, 2026
The Promise
The comparison table everyone screenshots measures one page. The architecture doc explains what happens at ten, and the answer changes which workloads this is for.
What this covers: the eight-crate layout, the single-isolate concurrency model, the robustness subsystem it forces, and what "drop-in replacement" actually buys. What this excludes: the MCP server, markdown extraction, and the hosted Cloud product.
What It Actually Does
Obscura is an Apache 2.0 headless browser engine written in Rust. It runs real JavaScript through V8 by way of deno_core, speaks the Chrome DevTools Protocol, and works with Puppeteer and Playwright without Chromium or Node.js anywhere on the machine.
The adoption is not small. Roughly 26,800 stars, 2,000 forks, 68 open issues and 57 open pull requests. The strongest signal is in the README and easy to miss: Cloudflare's Kitesurf began as a port of Obscura to Workers while they built their own agent-first browser. When an infrastructure company prototypes on your engine, the engine is real.
The table that travels:
Metric | Obscura | Headless Chrome |
|---|---|---|
Memory | 30 MB | 200+ MB |
Binary size | 70 MB | 300+ MB |
Page load | 85 ms | ~500 ms |
Startup | Instant | ~2s |
Anti-detect | Built in | None |
Six point seven times less memory, five point nine times faster page load. Both real, both measured at one page, and neither is an optimization in the sense most readers assume. Obscura is not a trimmed Chromium. It has its own DOM implementation, its own hand-written JavaScript globals, and a Web platform surface that grows one API at a time.
That is the honest frame for everything below. This is not Chrome made small. It is a different browser that implements less.
The Architecture, Unpacked

Caption: Focus on the third box. One isolate and one global mutex is the decision every other property in this diagram follows from, including the entire fourth box.
Three decisions carry the design, ranked by consequence.
One, a single V8 isolate per process. This is where the 30 MB comes from and where the concurrency ceiling comes from. Chrome isolates pages into separate processes and gets real parallelism plus real memory cost. Obscura shares one isolate and gets the inverse.
Two, a hand-built Web platform. obscura-dom is its own DOM tree. bootstrap.js provides document, window, navigator, location, observers, fetch and indexedDB as shims calling into Rust ops. The wiki ships a page titled "Adding a CDP method or Web API" with a three-step recipe, which tells you the surface is incomplete by construction and grows by hand.
Three, one crate per layer with no sideways calls. All DOM mutations funnel through op_dom to keep the JavaScript and Rust boundary narrow. That discipline is what makes the catch_unwind containment in mechanism three possible at all: there is exactly one place to wrap.
The Code, Annotated
The lock that defines the concurrency model
// From the architecture wiki. Any handler that wants to run JavaScript
// must take the global lock first.
let _guard = obscura_js::v8_lock::global().lock().await;
page.evaluate(expr).await
// ← THIS is the trick, and the ceiling. v8_lock::global() is a single
// tokio::sync::Mutex for the whole process. All pages share one V8
// isolate, and the isolate is single-threaded by design.
//
// So Target.createTarget from twenty concurrent clients works fine, but
// the twenty pages take turns executing JavaScript. The dispatcher stays
// responsive because process_with_interception spawns the actual work
// onto the tokio LocalSet and returns immediately. Responsive is not
// the same as parallel.
//
// Everything is on a LocalSet rather than a multi-threaded runtime for
// one reason: V8 is !Send. You cannot move the isolate between threads,
// so the whole async design is shaped around that single type bound.
Caption: One mutex, process-wide. The release ships an obscura-worker binary beside obscura for the parallel scrape command, which is the architecture telling you where parallelism actually lives.
Why a separate OS thread had to exist
// obscura-js/runtime.rs provides arm_watchdog and run_event_loop_bounded.
// The wiki states the reason outright:
//
// "terminates the isolate from a separate thread when synchronous work
// overruns a budget, because tokio::time::timeout cannot preempt
// synchronous V8"
//
// ← THIS is the part worth internalizing. An async timeout is cooperative.
// It fires when the task yields. Synchronous JavaScript never yields.
//
// while (true) {} // in a page, on an async-only design:
// // holds the global v8_lock forever
// // wedges EVERY page in the process
//
// So termination must come from outside the runtime entirely: a real OS
// thread calling into V8 to kill the isolate. Six mechanisms exist for
// this class of problem:
// 1. arm_watchdog / run_event_loop_bounded separate thread, bounds
// settle, nav pumps, --eval
// 2. cdp_watchdog.rs armed around every CDP command
// OBSCURA_CDP_COMMAND_TIMEOUT_MS
// 3. catch_unwind(op_dom) DOM panic -> null, not abort
// 4. tree.rs cyclic reparenting rejection tree walks cannot loop
// 5. OBSCURA_FETCH_TIMEOUT_MS scripted fetch, XHR, modules
// 6. process-level hard deadline one-shot fetch CLI backstop
Caption: Six defences, one root cause. Every one of them exists because pages share an isolate, which is the hidden line item behind the memory figure.
The build matrix is a real decision
# Four release archives, not one. Pick before you deploy.
# suffix rendering stealth transport TLS stack
# (none) yes no rustls
# -stealth yes yes wreq / BoringSSL
# -no-render no no rustls
# -no-render-stealth no yes wreq / BoringSSL
cargo build --release -p obscura-cli --bins --features render
cargo build --release -p obscura-cli --bins --features render,stealth
# ← The stealth build is materially more expensive to produce. It compiles
# BoringSSL from source and generates bindings, so it needs CMake, Clang,
# libclang-dev and llvm-dev on the box. The plain render build uses rustls
# and needs none of that. First build is ~5 min either way because V8
# compiles from source, cached after.
# Docker: distroless/cc:nonroot, no shell, no package manager, uid 65532,
# ~57 MB compressed.
docker run -d --name obscura -p 127.0.0.1:9222:9222 h4ckf0r0day/obscura
# ^^^^^^^^^ bind to loopback deliberately.
# -p 9222:9222 publishes CDP on every interface, and CDP is remote code
# execution by design. A mounted --storage-dir must be writable by 65532.
Caption: Two axes, four artifacts, and a real toolchain cost on the stealth side. The loopback bind in the documented command is not cosmetic, because an open CDP port is an open shell.
It In Action
Input: a Puppeteer script pointed at a running Obscura on port 9222, calling goto on a JavaScript-heavy page.
Step one, connect. Puppeteer opens a WebSocket. obscura-cdp/server.rs accepts and routes by sessionId, formatted as {targetId}-session.
Step two, dispatch. dispatch.rs routes the method and acquires v8_lock. If another page is mid-evaluation, this waits.
Step three, navigate. domains/page.rs hands to obscura-browser/page.rs, which calls navigate_with_wait. That fans out to obscura-net/client.rs for the HTTP fetch, obscura-dom/tree.rs to parse the HTML, and obscura-js/runtime.rs to run inline scripts against the bootstrap.js globals.
Step four, the watchdog arms. cdp_watchdog.rs wraps the command. If the page's synchronous JavaScript overruns OBSCURA_CDP_COMMAND_TIMEOUT_MS, a separate OS thread terminates the isolate rather than letting it hold the lock.
Step five, lifecycle. Events fire as init, commit, domcontentloaded, load, networkidle2, networkidle0. Your waitUntil blocks until the requested level, and Puppeteer's goto resolves on the matching Page.lifecycleEvent.
The numbers. One page: about 30 MB resident, 85 ms page load, instant startup against Chrome's roughly two seconds. Ten pages needing genuine JavaScript concurrency: ten processes, so a floor near 300 MB, because within a process they queue on one mutex. The per-page memory figure does not compound the way the headline implies, and the per-process figure does.
Why This Design Works, And What It Trades Away
It works because most agent and scraping workloads are not JavaScript-bound. They are network-bound. A page spends most of its wall clock waiting on HTTP, and process_with_interception releases the dispatcher during exactly that window. Under those conditions one isolate serves many pages perfectly well, and you keep the 30 MB.
The engineering quality is also visible in unglamorous places. One crate per layer with no sideways calls. All DOM mutation funneled through a single op so the unsafe boundary has one door. A distroless container running as uid 65532. A robots cache sitting in obscura-net beside the stealth client, which is a more considered posture than the marketing suggests.
What it trades away:
JavaScript parallelism. Covered in Insight One.
Web platform completeness. Covered in Insight Two.
Toolchain weight on the stealth path. BoringSSL from source means CMake, Clang, libclang and LLVM in your build image. The rustls path needs none of it. That is a real CI difference nobody mentions until the build breaks.
An open CDP port is an open shell. The documented Docker command binds to 127.0.0.1 for a reason, and the README says plainly that -p 9222:9222 exposes it on every interface. CDP can evaluate arbitrary JavaScript and read local storage. This is the single most likely way somebody gets hurt deploying this.
A dual-use posture that deserves naming. --stealth randomizes the TLS ClientHello and cipher order to impersonate a real browser. The sponsor list is three residential and mobile proxy providers, and one of them describes the pairing as making requests "blend into everyday traffic." That is evasion infrastructure. It has entirely legitimate uses, including agents that need to browse the open web without being throttled into uselessness, and it also defeats access controls that site operators chose deliberately. The project is straightforward about what it does. Whether your use clears your own legal and ethical bar is your call, not the tool's, and worth making before you deploy rather than after.
Technical Moats
The moat is not the architecture. Eight Rust crates around deno_core is a design a competent team could restate.
The moat is the Web API surface, accumulated one shim at a time. bootstrap.js implements document, window, navigator, location, observers, fetch and indexedDB by hand, each paired with a Rust op and a registration in build_extension(). Every site that works does so because somebody previously hit a missing API and added it. That backlog is years of unglamorous compatibility work, and it is exactly the asset Chromium has and nobody else does.
The second moat is CDP surface area. Being a genuine drop-in for Puppeteer and Playwright means implementing enough of a protocol Google designed for its own engine, including the event ordering clients depend on. Lifecycle events alone have six levels that must fire in the right sequence or goto hangs.
The third is the thing money cannot buy quickly: 26,800 stars, 2,000 forks, 57 open pull requests, and Cloudflare using it as a prototype base. That last one is a distribution asset and a validation signal at once.
Worth noting what is deliberately not a moat. The engine stays Apache 2.0 with a stated commitment to no feature gating, and monetization is a hosted Cloud product plus proxy sponsorships. That is the honest version of open core, and it puts the commercial pressure on operations rather than on withholding capability.
Insights
Insight One: every page in a process shares one JavaScript thread, and the memory figure is per process.
The architecture doc states it without hedging. All pages in a process share one V8 isolate. The isolate is single-threaded by design. obscura_js::v8_lock::global() is a tokio::sync::Mutex that serializes V8 work, and the entire async design runs on a LocalSet because V8 is !Send.
So the concurrency story is narrower than "lightweight browser" suggests. You can open many pages. One of them executes JavaScript at a time.
That is fine, and often better than fine, when pages are waiting on the network, which most scraping and agent work is. It is not fine when your pages run heavy client-side JavaScript, because they queue.
Now recompute the headline. Thirty megabytes is a per-process floor:
Concurrent JS-executing pages | Processes needed | Memory floor |
|---|---|---|
1 | 1 | 30 MB |
4 | 4 | 120 MB |
10 | 10 | 300 MB |
32 | 32 | 960 MB |
The project knows this. It is why release archives contain two binaries, obscura and obscura-worker, and why the README tells you to keep them in the same directory for the parallel scrape command. Parallelism is a process-level concern here, by design.
None of which makes the 30 MB wrong. It makes it a number about one process, quoted against a number about one Chrome, in a comparison where the scaling behaviour differs and neither column shows it.
Insight Two: "drop-in replacement" describes the protocol, not the platform.
Obscura speaks CDP and works with Puppeteer and Playwright. That is true and it is the genuinely clever positioning: adopt it by changing a connection string.
But look at what sits behind the protocol. obscura-dom is its own DOM tree implementation. bootstrap.js hand-provides the browser globals. And the wiki contains a contributor page called "Adding a CDP method or Web API" whose recipe is three steps: write a JavaScript shim in bootstrap.js, write a Rust op in ops.rs that performs the side effect, register the op in build_extension().
A project only needs that page if the Web platform surface is incomplete and expected to stay that way for a while. Which is not a criticism, it is arithmetic. Chromium is roughly thirty million lines. Obscura is eight crates. The gap is filled by however many APIs contributors have shimmed so far.
Which reframes the memory table completely. Thirty megabytes against two hundred is not the result of better engineering applied to the same problem. It is the result of implementing a smaller problem. The right question before adopting is not whether it is faster, it is whether the specific sites you target use only APIs that have been shimmed, and the only way to know is to run your own pages through it.
The practical test takes ten minutes: point it at your twenty most important target URLs, dump the text, and diff against what Chrome gives you. That is a far better adoption signal than any benchmark table.
Takeaway
Six separate safety mechanisms exist in this codebase, and all six trace to one sentence in the architecture doc: tokio::time::timeout cannot preempt synchronous V8.
An async timeout is cooperative. It fires when a task yields at an await point. Synchronous JavaScript never yields. So a page running while (true) {} cannot be stopped by any amount of async machinery, and because pages share one isolate behind one global mutex, that single page wedges every other page in the process.
The defences, all from one paragraph of the Robustness section:
Mechanism | What it contains |
|---|---|
| terminates the isolate from a separate OS thread; bounds post-load settle, navigation pumps and |
| one shared watchdog armed around every CDP command |
| a DOM panic returns null instead of aborting through V8's FFI frame |
cyclic reparenting rejection | tree walks cannot loop forever |
| bounds scripted fetch, XHR and module loads |
process-level hard deadline | final backstop on the one-shot fetch CLI |
That is the invoice for the single-isolate decision, and it is not visible anywhere in the comparison table. The memory saving was paid for in defensive engineering, and the bill was settled by someone who understood that V8's FFI frame turns a Rust panic into a process abort.
The transferable lesson has nothing to do with browsers. Any time you put a foreign runtime inside an async host, you inherit a preemption problem your runtime cannot solve, and you will end up building a watchdog on a real thread. Worth knowing before you choose the architecture rather than after.
TL;DR For Engineers
All pages in an Obscura process share one V8 isolate behind one
tokio::sync::Mutex. Many pages, one executing JavaScript at a time.The 30 MB figure is a per-process floor. Ten concurrent JavaScript-executing pages means ten processes and roughly 300 MB, which is why
obscura-workerships alongside the main binary.It is not a trimmed Chromium. Its own DOM, hand-written globals, and a wiki page explaining how to add the next missing Web API.
Six robustness mechanisms exist because
tokio::time::timeoutcannot preempt synchronous V8, including a watchdog on a real OS thread andcatch_unwindaround every DOM op.Four release variants across render and stealth. The stealth build pulls BoringSSL and needs CMake, Clang and LLVM in your image. Bind CDP to loopback, since an open 9222 is an open shell.
Explain It Like I'm New
Software that browses the web on your behalf has a practical problem. Modern pages are programs, not documents, so reading one properly means running its code. The usual answer is to run an invisible copy of Chrome, which works and is enormously heavy, because Chrome is built to render pixels for a human sitting in front of it.
Obscura asks whether you need all of that. An automated agent does not need smooth scrolling, video playback or a settings menu. It needs to fetch a page, run the page's code, and read the result.
So it takes the piece that actually matters, Google's JavaScript engine, and builds a minimal browser around it in Rust. The savings are large. Around a sixth of the memory and roughly six times faster page loads.
The catch is honest and worth understanding. The savings do not come from clever optimization of the same job. They come from doing a smaller job. Anything a page needs that has not been deliberately rebuilt is simply absent, and the list grows one addition at a time.
Which is the general shape of a tradeoff worth recognising. When something is dramatically lighter than the established option, the useful question is rarely how they optimized it. It is what they left out, and whether you needed it.
See It In Action
Architecture overview (wiki), the crate map, the request flow, the single-isolate section and the Robustness paragraph this issue is built on. Unusually candid for a project with commercial ambitions, and the best forty lines in the repo.
Adding a CDP method or Web API (wiki), the three-step recipe for extending the Web platform surface. Read it to understand exactly what "drop-in replacement" does and does not cover.
Run in production at scale (wiki), the page to read before deploying, given everything in Insight One about where parallelism lives.
Cloudflare's Kitesurf engineering post (blog), how a major infrastructure provider approached the same problem, having started from a port of this engine to Workers. The most useful outside perspective available.
Connect Puppeteer or Playwright (wiki), the ten-minute adoption test. Point it at your real target URLs and diff the output against Chrome before believing any benchmark.
Community Conversation
The Cloudflare connection is the headline community signal. Kitesurf's first prototype was Obscura ported to Workers. That is a large infrastructure team validating the approach by building on it, then building their own, which tells you both that the design is sound and that the moat is not the design.
Fifty seven open pull requests against 68 open issues is an unusual ratio. More inbound code than inbound complaints suggests contributors are shimming Web APIs faster than users are hitting gaps, which is exactly the dynamic the compatibility backlog needs.
The no-feature-gating commitment is stated in the README: the open-source engine stays Apache 2.0, fully featured, with monetization through hosted Cloud and proxy sponsorships. Worth holding them to, and worth noting it is a harder commercial path than the usual open-core split.
The sponsor list is three proxy providers, with discount codes in the README. That tells you who the current commercial user base is, and it is scraping operations rather than AI agent builders, whatever the positioning says.
The open question nobody has answered publicly: which Web APIs are missing. There is no compatibility matrix, no Web Platform Tests score, no list of known-unsupported sites. For a project whose entire adoption risk is platform completeness, that is the one document that would most reduce friction.
References
WebArena: A Realistic Web Environment for Building Autonomous Agents, Zhou et al., ICLR 2024, the benchmark that defined what agents must do in a browser
WebVoyager: Building an End-to-End Web Agent with Large Multimodal Models, He et al., ACL 2024, and the source of the argument that agents need real rendering rather than raw HTML
Cloudflare Kitesurf, the engineering account of building an agent-first browser, starting from a port of Obscura
Obscura architecture overview, the primary source for the isolate model and the robustness subsystem
Deno core, the V8 embedding layer Obscura builds its JavaScript runtime on
Chrome DevTools Protocol, the interface that makes the Puppeteer and Playwright compatibility possible
Mind2Web: Towards a Generalist Agent for the Web, Deng et al., NeurIPS 2023, useful counterweight on how much of the web agents actually need to render
Obscura is an Apache 2.0 headless browser engine in Rust that runs JavaScript through V8 via deno_core, speaks the Chrome DevTools Protocol, and works with Puppeteer and Playwright at roughly 30 MB against headless Chrome's 200-plus. Its defining architectural choice is a single V8 isolate per process behind one global mutex, which makes the memory figure a per-process floor, caps JavaScript execution at one page at a time, and forced six distinct robustness mechanisms because async timeouts cannot preempt synchronous V8. It matters because the weight saving comes from implementing a smaller Web platform rather than optimizing the same one, and that is the question to answer before adopting it.
Keep Going
The habit from this issue: when a tool is dramatically lighter than the incumbent, do not ask how they optimized it. Ask what they left out, then check whether your workload needed it. Here the answer was in a contributor wiki page about adding missing Web APIs.
SnackOnAI runs this teardown weekly on the systems engineers actually deploy, browser engines, agent harnesses, serving stacks, and the architecture docs that quietly contradict the comparison table. No announcements, no press release summaries. Subscribe at snackonai.com and join 10,000+ engineers reading it.
Forward this to whoever on your team is about to run a scraping fleet on a headless browser.
Sponsored Ad If you enjoy practical AI insights, check out SnackOnAI and support the newsletter by subscribing, sharing, and exploring our sponsored ad, it helps us keep building and delivering value 🚀
Exploring AI Voice With SuperBloom
Scaling a campaign globally means finding a voice that resonates everywhere—without losing the overall message. For Deel's "Feeling of Deeling" campaign, agency SuperBloom needed exactly that: a consistent brand voice across markets, deployed fast, without sacrificing quality or consent. They built it with Branded AI Voice, powered by real, professional talent.
SuperBloom and Voices break down how the campaign came to life in this on-demand video session—from strategic talent selection through seamless production workflows and global scale, to the governance that future-proofs an audio strategy built to last. You'll hear directly from the team on how they made this happen, plus their advice if you're looking to explore AI voice for your brand.
If you're a marketing executive, agency creative, or brand leader mapping campaigns in international markets, watch this on-demand session to get a real playbook, not a hypothetical—lessons any creative team can apply to their own global rollout.


