Parsing HTML Without innerHTML in MV3

Extract data from untrusted HTML in an MV3 extension: why DOMParser is unavailable in the worker, using an offscreen document, and why innerHTML is the wrong tool even where it works.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

You fetch a page or a feed in the service worker and need three fields out of it. DOMParser is not defined, document is not defined, and the tempting fallback — a regular expression over the markup, or innerHTML on some element you inject somewhere — is both fragile and, in the second case, a code-execution vector. This page is part of Offscreen Documents & DOM Access and covers a parsing path that is safe for hostile input.

Root cause: no parser in the worker, and innerHTML is not a parser

The worker has no HTML parser because it has no document. innerHTML, where a document does exist, is not a parsing tool at all — it constructs live DOM inside your page, which means inline event handlers and <img onerror> attributes become real, attached, executable things. DOMParser.parseFromString is different in exactly the way that matters: it builds an inert document whose scripts never run and whose resources are never fetched.

Four ways to get fields out of markupRegular expressions, innerHTML, DOMParser in an offscreen document and a content script compared on safety, correctness and availability.ApproachSafe for hostile inputHandles real markupAvailable in a workerRegular expressionsYesNoYesinnerHTML on a live nodeNoYesNoDOMParser (offscreen)YesYesVia documentContent script in a tabNoYesVia injection
Only the third row is both safe and correct in a service worker context — the others trade one for the other.

Step 1. Send the markup, not the DOM

The worker fetches, the offscreen document parses, and only extracted plain data comes back. Nothing DOM-shaped crosses the boundary, which is both a requirement of the message channel and a useful discipline: the worker never handles anything that could still execute.

 1// background.js
 2export async function extractArticles(url) {
 3  const response = await fetch(url, { credentials: "omit" });
 4  if (!response.ok) throw new Error(`fetch failed: ${response.status}`);
 5  const markup = await response.text();
 6
 7  await ensureOffscreenDocument();
 8  const result = await chrome.runtime.sendMessage({
 9    target: "offscreen-parse",
10    type: "EXTRACT_ARTICLES",
11    markup,
12  });
13
14  if (!result?.ok) throw new Error(result?.error ?? "parse failed");
15  return result.articles;   // plain objects only
16}

Execution context: Extension service worker. credentials: "omit" keeps ambient cookies out of a fetch whose only purpose is to read public markup, which also keeps the response from varying per user in ways your parser has not seen. The worker deliberately never inspects markup itself — no regular expressions, no substring checks — because every such shortcut is a second, unversioned parser that will disagree with the real one.

Step 2. Parse into an inert document

parseFromString with text/html produces a Document that is not attached to anything. Scripts in it do not run, <img> sources are not fetched, and <iframe> elements are inert.

 1// offscreen/parse.js
 2chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
 3  if (message?.target !== "offscreen-parse") return;
 4
 5  (async () => {
 6    try {
 7      const doc = new DOMParser().parseFromString(message.markup, "text/html");
 8
 9      const articles = [...doc.querySelectorAll("article")].slice(0, 200).map((node) => ({
10        title: node.querySelector("h1, h2")?.textContent?.trim() ?? "",
11        // Read the ATTRIBUTE, not the resolved property: an inert document has no base URL,
12        // so href resolution would be meaningless here.
13        href: node.querySelector("a[href]")?.getAttribute("href") ?? "",
14        summary: node.querySelector("p")?.textContent?.trim().slice(0, 400) ?? "",
15      }));
16
17      sendResponse({ ok: true, articles });
18    } catch (error) {
19      sendResponse({ ok: false, error: String(error) });
20    }
21  })();
22
23  return true;
24});

Execution context: Hidden offscreen document with DOM_PARSER among its reasons. The slice(0, 200) cap is deliberate: a hostile or broken source can contain a hundred thousand matching nodes, and an unbounded map is how a parse turns into an out-of-memory crash of the renderer. Reading getAttribute("href") rather than .href is the correct choice in an inert document — the property would resolve against the document’s own null base and produce nothing useful, so resolve it in the worker against the URL you fetched.

Step 3. Resolve and validate in the worker

Relative URLs, absolute URLs and javascript: URLs all arrive as strings. Resolving them against the fetch URL and rejecting non-HTTP schemes belongs in the worker, where the original URL is known.

 1// background.js
 2function resolveLink(raw, baseUrl) {
 3  try {
 4    const url = new URL(raw, baseUrl);
 5    // Only http(s) survives. javascript:, data: and mailto: are dropped.
 6    return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
 7  } catch {
 8    return null;
 9  }
10}
11
12export async function extractAndClean(url) {
13  const articles = await extractArticles(url);
14  return articles
15    .map((a) => ({ ...a, href: resolveLink(a.href, url) }))
16    .filter((a) => a.title && a.href);
17}

Execution context: Extension service worker. Doing the resolution here rather than in the offscreen document keeps the parser a pure function of the markup and puts the URL policy in one place. The scheme check is the part that must not be skipped: a javascript: URL rendered into a link in your popup is a live code-execution path that no content security policy will catch, as noted in extension security and CSP hardening.

Where each responsibility livesFetching and URL policy stay in the worker, parsing happens in the inert document, and only validated plain data reaches the UI.Worker: fetchcredentials omittedDocument: parseinert, scripts never runWorker: resolve + filterhttp(s) onlyonly clean records reach the interfacePopup or side paneltextContent, never innerHTML
The parser never sees your base URL and the worker never sees a DOM node — each side does one job.

Step 4. Render without reintroducing the risk

Having parsed safely, the last chance to undo it is the render. Build elements and assign textContent; the moment a string of markup is assigned to innerHTML, everything above was wasted.

 1// popup.js
 2function renderArticles(list, articles) {
 3  list.replaceChildren();
 4  for (const article of articles) {
 5    const li = document.createElement("li");
 6    const link = document.createElement("a");
 7    link.href = article.href;              // already validated in the worker
 8    link.textContent = article.title;      // text, not markup
 9    link.rel = "noopener noreferrer";
10    li.append(link);
11    list.append(li);
12  }
13}

Execution context: Popup or side panel renderer, under the extension content security policy. textContent cannot introduce elements, so no amount of markup in article.title becomes DOM. rel="noopener noreferrer" matters because these links point at pages you did not write — without it the opened page gets a handle on the opener.

Step 5. Bound the work so a hostile page cannot exhaust the renderer

An inert document is safe from code execution but not from size. A ten-megabyte response with deeply nested markup will happily consume a renderer’s memory during parsing, and the offscreen document has no UI to show that anything is wrong — it simply dies, taking your reply with it.

Where a parse job's budget goesIndicative cost of fetching, parsing, querying and serialising a large HTML document, showing that parse dominates.fetch + text()90 msnetwork boundparseFromString320 msscales with document sizequerySelectorAll45 msone passMap to plain objects18 mscapped at 200 nodesMessage back to the worker6 msplain data only
Parsing dominates, and it scales with bytes — which is why the byte cap belongs before the fetch body is read, not after.
 1// background.js — refuse oversized bodies before they reach the parser
 2const MAX_BYTES = 2 * 1024 * 1024;
 3
 4async function readBounded(response) {
 5  const declared = Number(response.headers.get("content-length"));
 6  if (Number.isFinite(declared) && declared > MAX_BYTES) throw new Error("response-too-large");
 7
 8  const buffer = await response.arrayBuffer();
 9  if (buffer.byteLength > MAX_BYTES) throw new Error("response-too-large");
10  return new TextDecoder().decode(buffer);
11}

Execution context: Extension service worker. Checking content-length first rejects the obvious cases without downloading, and re-checking the actual byte length covers a missing or lying header. Two megabytes is a generous ceiling for markup you intend to extract three fields from; picking a number and enforcing it is what keeps one bad source from taking down the parse path for every other source.

Cross-browser variation

  • Chrome and Edge 109+: DOMParser requires an offscreen document. parseFromString produces an inert document there exactly as it would in a page.
  • Firefox 121+: DOMParser is available directly in the MV3 background context, so no offscreen document is needed. The parsing code is otherwise identical.
  • Safari 17+: no offscreen documents and no background DOM. Parse in a content script in a tab you already have access to, and treat everything it returns as untrusted — a content script shares a process with the page.
  • All engines: parseFromString never executes scripts or fetches subresources. That property is what makes it safe, and it is not shared by innerHTML in any engine.

Verification

  1. Parse markup containing <script>alert(1)</script> and <img src=x onerror=alert(1)>. Nothing should execute, in any context.
  2. Parse markup with a relative href and confirm the worker resolves it against the fetch URL, and that a javascript: href is dropped entirely.
  3. Parse a document with more than 200 matching nodes and confirm the cap holds.
  4. Check the browser task manager: the offscreen document should appear during the parse and disappear immediately afterwards.

FAQ

Is a regular expression really so bad for this?

For a single, stable, machine-generated field it is defensible. For real HTML it is not: nesting, attribute quoting, comments and CDATA all defeat it, and the failure mode is silently wrong data rather than an error. If the source ever changes shape, a parser adapts and a pattern does not.

Does DOMParser run scripts in the markup?

No. The resulting document is inert: scripts do not execute, and resources are not loaded. This is the specific guarantee that makes it the right tool for untrusted input.

Can I use the DOM parser in a content script instead?

You can, and it avoids the offscreen document — but the content script runs in the page’s process, so you are parsing hostile input next to hostile code. Use it as the Safari fallback, not as the primary path.

Why read getAttribute instead of the href property?

Because an inert document has no base URL, so the property has nothing to resolve against. Reading the raw attribute and resolving it in the worker, against the URL you actually fetched, is both correct and easier to test.

Other MV3 Architecture & Extension Lifecycle Resources