← 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`:

- Never put tab state in module-level JS. Service workers terminate every 30 seconds. Use `chrome.storage.session` for anything that outlives a single event handler.
- Rehydrate before accepting commands. If you have a long-poll loop, make the first iteration wait for a rehydration promise. Otherwise commands arriving right after SW wake-up will resolve lookups against an empty Map.
- session.destroy must not silently no-op. Return a structured "session not found" result instead of `{ closed: false }`, so the client can distinguish real orphans from benign double-destroys.
- Don't create placeholder tabs you don't immediately use. Every tab is exposure surface for SW-restart leaks. Create tabs lazily on first use, not eagerly on session creation.
- Assume `chrome.scripting.executeScript` cannot target non-http URLs, including your own extension pages and `about:blank`. If you need to run code without a real webpage, use offscreen documents with static handlers (not `eval`).

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