Reaching 82% JS Coverage: MutationObserver in a Headless Engine
Plasmate is not a browser. It's a compiler that turns HTML into a Semantic Object Model. But the modern web doesn't serve HTML anymore. It serves JavaScript that builds HTML. To compile the real web, we need to run the real web's JavaScript.
This is the story of how we took Plasmate's JS coverage from 71% to 82% across 100 real-world sites, and what we learned about the gap between "has a V8 engine" and "runs React."
The Problem
Plasmate embeds V8 (Google's JavaScript engine) and injects a DOM shim before running page scripts. The shim provides document.createElement, appendChild, innerHTML, event dispatching, and dozens of other APIs. It's substantial - around 2,200 lines of JavaScript running inside V8 before any page code executes.
When we first ran the coverage scorecard against 100 real-world sites, 71 produced good SOM output. The other 29 were either "thin" (too few elements extracted) or failed entirely. The thin sites were almost exclusively SPAs: React, Next.js, Vue, and Angular applications that depend on browser APIs our shim didn't fully implement.
The Scorecard
Before diving into fixes, we built an automated coverage pipeline. For each URL, Plasmate:
- Fetches the HTML
- Compiles a "pre-JS" SOM (HTML only)
- Runs all inline and external scripts through V8
- Serializes the post-JS DOM back to HTML
- Compiles a "post-JS" SOM
- Keeps whichever SOM has more elements (prevents JS from degrading content)
- Reports element count, JS success/failure rates, and timing
A site is classified as:
- Full - 15+ elements, or 40+ elements with no interactive requirement
- Thin - Under 15 elements, or under 300 bytes of SOM
- Failed - HTTP error, non-HTML, or network failure
Phase 1: Headers and Safety (71% to 80%)
The first 9% came from two changes that didn't touch the JS runtime at all:
Browser-realistic HTTP headers
Many sites were returning bot-detection pages or degraded HTML because our HTTP client was sending minimal headers. Adding a realistic User-Agent, Accept, Accept-Language, and Accept-Encoding moved several sites from thin to full with zero performance cost.
Pre/post SOM comparison
Some sites have inline JavaScript that actually removes content (loading spinners, skeleton screens, GDPR banners that hide everything). We added a comparison step: after JS runs, if the post-JS SOM has fewer elements than the pre-JS SOM, we keep the pre-JS version. This is a safety net, not a feature - it catches cases where JavaScript makes things worse.
JS safety budgets
We added configurable limits to prevent runaway scripts from consuming resources:
--max-external-scripts 3 # Max external scripts to fetch
--max-external-script-kb 64 # Max size per external script
--max-external-total-kb 128 # Total external JS budget
--external-script-timeout-ms 2000
These limits are intentionally conservative. We're not trying to run every script - just enough to render the primary content.
Phase 2: MutationObserver (80% to 82%)
This was the big one. Our DOM shim had MutationObserver as a no-op stub:
function MutationObserver(callback) { this._callback = callback; }
MutationObserver.prototype.observe = function() {};
MutationObserver.prototype.disconnect = function() {};
MutationObserver.prototype.takeRecords = function() { return []; };
This is enough to prevent crashes, but it breaks every SPA framework. React, Vue, and Angular all use MutationObserver to track DOM changes and drive their rendering loops. When the observer never fires, the framework thinks nothing changed, and rendering stalls.
The implementation
We replaced the stub with a real implementation. Every DOM mutation method (appendChild, removeChild, insertBefore, replaceChild, setAttribute, removeAttribute, textContent setter) now creates a MutationRecord and queues it for delivery.
Observer configuration is respected:
childList- tracks additions and removalsattributes- tracks attribute changes (withattributeOldValue)characterData- tracks text content changessubtree- recursive observation
Mutation delivery is batched using a microtask-like flush. After all synchronous JS finishes, we drain pending mutations and call observer callbacks. This matches browser behavior - mutations are delivered asynchronously, not inline.
Result: Amazon.com went from thin (0 elements) to full (151 elements). eBay went from failed to full (210 elements). Both are heavy React/Next.js SPAs that depend entirely on MutationObserver for rendering.
Phase 3: URL Polyfill Conflicts
One of the more subtle bugs: our DOM shim defined URL twice. Early in the shim, we had a polyfill function:
function URL(url, base) {
// parse protocol, hostname, pathname, search, hash
this.searchParams = new URLSearchParams(this.search);
}
But later in the same shim, a separate section defined:
var URL = {
createObjectURL: function() { return 'blob:null'; },
revokeObjectURL: function() {}
};
This plain object silently overwrote the constructor. new URL('https://nytimes.com') would throw TypeError: URL is not a constructor, because URL was now a plain object with two static methods.
The fix: detect whether the global URL is already constructible using try-catch probing, and only define our polyfill if it fails. For the static methods, we attach them directly to the existing URL function without replacing it:
var __plasmate_url_ok = false;
try {
if (typeof URL === 'function') new URL('https://a.com');
__plasmate_url_ok = true;
} catch (e) { /* ignore */ }
if (!__plasmate_url_ok) {
function URL(url, base) { /* ... */ }
globalThis.URL = URL;
}
// Attach static methods without replacing the constructor
window.URL.createObjectURL = window.URL.createObjectURL || function() { return 'blob:null'; };
window.URL.revokeObjectURL = window.URL.revokeObjectURL || function() {};
Phase 4: Module Scripts
V8 can parse ES modules, but our shim doesn't provide a module loader. External scripts with type="module" were being fetched and compiled, producing SyntaxError: Cannot use import statement outside a module on every import line.
The fix was one line in extract.rs: skip external scripts when type="module" is set. We already skipped inline module scripts; this extended the same logic to external ones.
What's Still Thin
14 sites remain thin after all improvements. They fall into clear categories:
| Category | Sites | Root Cause |
|---|---|---|
self not defined |
crates.io, Apple Docs, Medium, Khan Academy, x.com | Webpack/bundler code references self as the global scope |
| Heavy SPA hydration | stripe.com, bing.com, booking.com | Requires full React/framework hydration pipeline |
| Auth wall | accounts.google.com, login.microsoftonline.com, instagram.com | Login pages with minimal public content |
| Anti-bot | nytimes.com | Complex inline JS challenges |
The self = globalThis fix has been implemented but is not yet reflected in the scorecard. That should push us to 84-85%.
The Fundamental Tension
There's an inherent design tension in what Plasmate does. We're not a browser. We don't render pixels. We don't support CSS layout. We don't implement the full Web Platform API surface.
But we need to run enough JavaScript for the page to populate its DOM with content. That means implementing just enough of the browser API surface for frameworks to function, without building a second Chromium.
The coverage scorecard is our way of staying honest about where that line is. Every site that's full validates our approach. Every site that's thin tells us exactly which API is missing.
The goal is not 100%. The goal is: does the agent get enough signal from this page to do useful work?
For 82% of the top 100 sites, the answer is yes.
Live scorecard: Coverage (JS) | Source: GitHub