← Tap · Blog

Debugging Chrome MV3's "Many Empty Tabs" Bug

April 11, 2026 · Leon Ting · 9 min read · A chrome.storage.session post-mortem

Three weeks after shipping Tap's Chrome Extension runtime, a user filed a simple question: "why does each tap run open so many empty tabs?"

They had nine chrome://newtab/ tabs sitting in their browser, all titled "New Tab", all from our extension. None of them should have existed — every session Tap opens gets explicitly destroyed at the end.

I spent an afternoon tracking this down and fixing it three different wrong ways before finally reading the Chromium extension docs properly. This post is the story of what I found, because every mistake I made is a Chrome MV3 rule that isn't obvious until you've hit it.

The symptom

Tap's extension manages one Chrome tab per logical session — session.create opens a tab, the tap operates on it, session.destroy closes it. The sessions Map lives in the service worker:

// extension/background.js
const sessions = new Map()  // sessionId → { tabId, url, ... }

chrome.runtime.onMessage.addListener(async (msg) => {
  if (msg.type === 'session.create') {
    const tab = await chrome.tabs.create({ active: false })
    const sessionId = crypto.randomUUID().slice(0, 8)
    sessions.set(sessionId, { tabId: tab.id })
    return { sessionId }
  }
  if (msg.type === 'session.destroy') {
    const sess = sessions.get(msg.sessionId)
    if (!sess) return { closed: false }  // ← the bug
    await chrome.tabs.remove(sess.tabId)
    sessions.delete(msg.sessionId)
    return { closed: true }
  }
})

Every session created by the extension should get destroyed. But the user saw orphan tabs accumulating overnight. So something was leaking.

Why the leak happens (and why MV3 developers keep hitting it)

The sessions Map is a JavaScript object at module scope. Every extension developer writes code like this. It works fine — until you learn that MV3 service workers get terminated every ~30 seconds of idle time.

From the Chromium extension team's guidance:

"Extension service workers are short-lived. The runtime terminates them when they're not actively processing events, typically after 30 seconds of idle. On next activity, a fresh instance loads the background script from the top."

— Chromium Extensions documentation

"Fresh instance loads from the top" is the load-bearing phrase. It means const sessions = new Map() runs again, and the Map is empty. Every session created by a previous SW instance becomes invisible to the new one.

But Chrome tabs don't live in the service worker. They live in the browser process. They persist.

So the state diverges: the Map thinks there are zero sessions, the browser has seven orphan tabs from sessions the previous SW instance knew about. When the client eventually calls session.destroy for any of those sessionIds, the handler runs its if (!sess) return { closed: false } branch and silently does nothing. The tab leaks forever.

This is the class of MV3 bug that's invisible until you accumulate enough of them to notice. Our user had nine orphans because they'd been running the extension for a few days.

Wrong turn #1: about:blank isn't accessible

My first diagnostic idea was to intercept the leak earlier. If session.create creates a tab on chrome://newtab/ (the default for chrome.tabs.create({ active: false }) with no URL), maybe switching to about:blank would let me attach a debug hook to the tab from the service worker.

// Attempted fix: open on about:blank instead
const tab = await chrome.tabs.create({ url: 'about:blank', active: false })

Result:

Error: Cannot access contents of url "about:blank".
Extension manifest must request permission to access this host.

Wait — host_permissions: ["<all_urls>"] is in the manifest. Why is about:blank rejected?

Turns out <all_urls> does not match about:blank unless the frame was opened from a parent whose URL matches. Top-level about:blank tabs with no opener have no matchable origin. The only way to grant access is via match_about_blank: true in a content_scripts manifest entry — which is for sub-frames, not standalone tabs.

MV3 rule #1: <all_urls> host permission does not grant access to top-level about:blank tabs. Standalone about:blank is not scriptable from a service worker via chrome.scripting.executeScript.

Wrong turn #2: chrome-extension:// isn't self-scriptable either

OK, if about:blank doesn't work, what about a page from our own extension? Extensions can always access their own origin, right?

I shipped a minimal blank.html in the extension bundle and pointed session.create at it:

const blankUrl = chrome.runtime.getURL('blank.html')
const tab = await chrome.tabs.create({ url: blankUrl, active: false })

Result:

Error: Cannot access contents of url
"chrome-extension://nmhmmpljcccdgbagjeemobmfhpoddaba/blank.html".
Extension manifest must request permission to access this host.

This surprised me. Adding the blank.html to web_accessible_resources didn't help either. Digging into Chromium's extension mailing list turned up the actual rule:

"Extensions cannot run content scripts in extension pages. This is a fundamental restriction in MV3. executeScript is only for web pages, just like content_scripts, but when you open an extension page — it has a chrome-extension:// URL. To run code in your own extension pages, include a <script> tag directly in the HTML."

— chromium-extensions@chromium.org
MV3 rule #2: chrome.scripting.executeScript cannot inject into chrome-extension://* URLs, even for your own extension's pages, regardless of web_accessible_resources. The sanctioned way to run code in your own extension pages is to ship the code as a bundled script tag in the HTML file.

Wrong turn #3: unsafe-eval is banned at the manifest layer

Next idea: use an offscreen document. Offscreen documents are the MV3-sanctioned way to use DOM APIs from a service worker context. I wanted to run arbitrary tap expressions that use DOMParser, so I set up an offscreen.html with a message handler:

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.target !== 'offscreen') return
  if (msg.type === 'eval') {
    try {
      // Run the expression in the offscreen document's context,
      // which has DOMParser and other DOM APIs available.
      const result = (0, eval)(msg.data.expression)
      sendResponse({ ok: true, result })
    } catch (e) {
      sendResponse({ ok: false, error: e.message })
    }
  }
  return true
})

The default MV3 CSP blocks eval(), so I added the usual override to the manifest:

{
  "content_security_policy": {
    "extension_pages": "script-src 'self' 'unsafe-eval'; object-src 'self'"
  }
}

Chrome's response:

Failed to load extension.
'content_security_policy.extension_pages': Insecure CSP value "'unsafe-eval'"
in directive 'script-src'. Could not load manifest.

This one I definitely did not know. In MV2, you could opt into 'unsafe-eval' in the extension CSP if you really wanted to. In MV3, you cannot — the manifest validator rejects it at load time. There is no flag, no override, no compatibility mode.

MV3 rule #3: 'unsafe-eval' is forbidden in content_security_policy.extension_pages. The manifest fails to load. You cannot run eval() or new Function() in any part of an MV3 extension — service workers, offscreen documents, or options pages.

Stop. Read the rules.

At this point my user interrupted me with the right question: "shouldn't you learn the rules before coding?"

They were right. I'd been trial-and-erroring a permission system that has been publicly documented for years. One afternoon of reading the Chromium extension docs, the MV3 migration guide, and the chromium-extensions mailing list turned up the complete picture:

  1. Service workers terminate on ~30s idle and reload from the top. All module-level JS state is lost.
  2. chrome.scripting.executeScript cannot target chrome://, chrome-extension://, or standalone about:blank URLs.
  3. 'unsafe-eval' is forbidden in any extension page CSP, including offscreen documents.
  4. Service workers have no DOM APIs. No DOMParser, no document, no window. Offscreen documents are the workaround, but they inherit the extension CSP (see rule 3), so they still can't eval().

Rules 1-4 are mutually reinforcing. MV3 is not hostile to extensions; it's very hostile to arbitrary code execution in extensions. Every rule is a piece of the same principle: extensions must ship their code as static files, and service workers should be short-lived event handlers with persistent data in chrome.storage.

Which meant our sessions Map was architecturally wrong from the start.

The real fix: persist the Map

chrome.storage.session is a per-browser-session in-memory KV store that survives service worker restarts. It's the MV3-sanctioned place for state that should outlive the SW but not the browser session. A perfect match for our sessions Map.

The fix is 40 lines of boilerplate:

// extension/background.js

const sessions = new Map()

async function rehydrateSessions() {
  const stored = await chrome.storage.session.get('tap_sessions')
  const map = stored?.tap_sessions
  if (!map) return
  for (const [sid, s] of Object.entries(map)) {
    // Verify the tab still exists — the user may have closed it
    // manually while the SW was down.
    try {
      await chrome.tabs.get(s.tabId)
      sessions.set(sid, s)
    } catch { /* tab gone — drop the session */ }
  }
}

async function persistSessions() {
  await chrome.storage.session.set({
    tap_sessions: Object.fromEntries(sessions),
  })
}

// Kick off rehydrate at SW startup. pollLoop / command handlers
// must wait for this promise before doing any sessionId → tabId lookup.
const rehydrateReady = rehydrateSessions()

Every mutation to sessions now writes through to chrome.storage.session. Every command handler that reads from sessions waits for rehydrateReady before the first lookup. session.destroy on a previously-orphaned session now finds the session in the rehydrated Map and closes the tab correctly.

The fix was in the wrong place all along. I'd been looking for a way to make script injection work on a placeholder tab. The actual bug was that the Map was never the source of truth.

The four bugs that were actually one bug

Once the persistence fix landed, three sibling issues fell out of the same root cause:

Nav-case orphan leak. The extension's nav handler had a branch that, if the current tab was on chrome://newtab/, would create a new tab at the target URL and update the session to point at it — but it never closed the original placeholder tab. Every tap.run against an http destination was leaking the initial placeholder. With persistence, this was trivial to fix: call chrome.tabs.update(tabId, { url }) on the original tab in place. chrome://newtab/ is perfectly navigable via tabs.update; the old "create a replacement" branch was unnecessary and leaky.

Wasted main session per MCP workflow. Tap's MCP server was eagerly calling sessionCreate on the first non-local tool call, opening a browser tab that was immediately unused by tap.run (which creates its own dedicated session). Pure tap.run workflows — the dominant Claude Code usage pattern — were paying one wasted placeholder tab per MCP session, and that tab was the longest-lived resource most exposed to SW-restart orphan leaks. Making the main session lazy (materialized only on first use of the interactive API) eliminated the waste and removed the biggest single source of leaked tabs.

Deno.mainModule regression. While verifying the fix, the compiled tap binary errored with "Playwright is not available in this binary" on the first MCP call. Chasing it, I found that Tap's runtime-detection code used !Deno.mainModule.startsWith("file://") to distinguish compiled binaries from source, and at some point in the last few Deno releases that check stopped working — both source and compiled now return file://-prefixed main modules. Switching to Deno.execPath() fixed it. This one's completely unrelated to Chrome MV3, but it was blocking verification, so it shipped as part of the same release.

All four — the persistence bug, the nav leak, the main-session waste, and the Deno detection regression — went out in Tap v0.11.3. The root was "extensions make assumptions about state that MV3 doesn't honor", and everything downstream of that was just different ways of tripping over the same wire.

What this means for browser automation tools

If you're building an MV3 Chrome extension that runs long-lived operations — automation, scraping, multi-step workflows — read this as a checklist before you write your first chrome.tabs.create:

The fundamental tension MV3 is trying to resolve is: "extensions should not execute arbitrary code, and their state should be recoverable from disk". Tools like Tap — which, by design, run lots of browser automation on behalf of AI agents — need to live within both constraints. Everything we shipped in v0.11.3 was us learning to do that properly.


Tap: deterministic browser automation for AI agents

Tap compiles AI understanding of a website into a deterministic program that runs forever at $0 per execution — no LLM call per step, no surprise tab leaks, self-healing when sites change. The Chrome Extension runtime is MIT-licensed and open source. The bug this post is about is fixed in v0.11.3.

curl -fsSL https://taprun.dev/install.sh | sh

taprun.dev · GitHub · v0.11.3 release notes