Querying the Active Tab Safely in MV3

Avoid flaky results when getting the active tab from a service worker or popup: currentWindow vs lastFocusedWindow, activeTab grant timing, and undefined tab.url without permission.

Published June 19, 2026 Updated June 19, 2026 7 min read
Table of Contents

The symptom is a tabs.query call that returns an empty array — or returns a tab with url: undefined — even though the user clearly has a tab open and focused. This guide is part of the Tabs API & Window Management reference.

The root cause is almost always one of two mismatches: using currentWindow: true from a context that has no window, or reading tab.url without the permission that unlocks it. Both are silent API misuses — Chrome returns an empty array or an undefined field rather than throwing an error, so the bug looks like a timing issue until you know what to look for.

Root cause: execution context determines what “current” means

A service worker has no browsing context. It is not embedded in a tab, a popup, or any window. When you pass currentWindow: true to tabs.query from a service worker, the browser evaluates “the window that contains the calling context” — and finds nothing. The result is an empty array [].

A browser-action popup does live inside a window. From popup code, currentWindow: true resolves correctly to the window that contains the popup’s tab.

Service worker wake-up
        │
        ├─ tabs.query({ active: true, currentWindow: true })
        │         └─ "current window" = none → returns []
        │
        └─ tabs.query({ active: true, lastFocusedWindow: true })
                  └─ "last focused window" = the window the user was in → returns [tab]

The activeTab grant adds a second dimension: it is issued at the moment of a user gesture (clicking the browser action, invoking a context menu, using a keyboard shortcut). A service worker that wakes up reactively — from an alarm, a network event, or a runtime.onMessage that arrived without a user gesture — never receives the activeTab grant for the current page.

Step-by-step solution

Step 1: use lastFocusedWindow in the service worker

Replace every currentWindow: true in service-worker code with lastFocusedWindow: true.

1// service-worker / background.ts
2async function getActiveTab(): Promise<chrome.tabs.Tab | undefined> {
3  const tabs = await chrome.tabs.query({
4    active: true,
5    lastFocusedWindow: true,
6  });
7  return tabs[0]; // undefined if no window is focused (e.g., minimized)
8}

Execution context: Service worker background context. lastFocusedWindow: true resolves to the window that last had keyboard focus, which is almost always what the user considers “the current window.” Returns undefined when all windows are minimized or the browser is hidden — always guard the return value. Firefox and Edge behave identically. Safari correctly returns the last focused window on macOS; on iOS there is only one window, so the distinction is moot.

Step 2: keep currentWindow in popup and options-page code

From a popup or options page you are inside a window, so currentWindow: true is both correct and more precise — it avoids accidentally targeting a different window the user opened since the popup appeared.

1// popup/popup.ts  (runs in the popup renderer, not the service worker)
2async function getActiveTabFromPopup(): Promise<chrome.tabs.Tab | undefined> {
3  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
4  return tab;
5}

Execution context: Popup renderer process. The popup always lives inside a window, so currentWindow resolves correctly. If you copy this snippet into the service worker it will silently return an empty array — the context switch is the danger.

Step 3: understand why tab.url is undefined

tab.url (and tab.pendingUrl, tab.title) are blanked out unless one of the following is true:

  1. The extension has declared "tabs" in permissions in manifest.json.
  2. The extension has a matching host permission for the tab’s origin (e.g., "*://*.example.com/*").
  3. The tab was granted via the activeTab permission during an active user gesture.

The tab object itself is always returned — only the sensitive string fields are gated. This means a query that appears to “work” (returns an array with one item) can still be useless because the URL is undefined.

1// Declare in manifest.json — this grants URL access for ALL tabs
2{
3  "permissions": ["tabs"]
4}
5
6// Or declare a host permission to limit URL access to specific origins
7{
8  "host_permissions": ["*://*.example.com/*"]
9}

Execution context: manifest.json, evaluated at install and extension-reload time. Using "tabs" triggers a Chrome Web Store warning (“Read your browsing history”). Prefer host permissions scoped to the domains your extension actually needs, or rely on activeTab when access is always user-initiated.

Step 4: confirm the activeTab grant is still active

The activeTab grant is valid from the moment of the user gesture until the tab navigates to a new origin. If your extension performs async work (awaiting a network response, reading storage) before calling tabs.query, the grant may still be active — but if the user navigates in that window during the await, the grant is lost.

 1// In a browser-action click handler — the grant is live here
 2chrome.action.onClicked.addListener(async (tab) => {
 3  // tab is passed directly to the handler — no query needed
 4  // This is the safest way to get the active tab with activeTab permission
 5  if (!tab.id) return;
 6
 7  // Perform work using the passed tab, not a subsequent query
 8  await chrome.scripting.executeScript({
 9    target: { tabId: tab.id },
10    func: () => document.title,
11  });
12});

Execution context: Service worker. The tab argument passed to chrome.action.onClicked is the active tab at the moment of the click — use it directly rather than querying again. If you must query later (e.g., in a debounced handler), check that the tab ID is still valid with chrome.tabs.get(tabId).catch(() => undefined) before operating on it.

Step 5: handle undefined and closed tabs defensively

Tabs close between the query and the action. IDs go stale. Always type-narrow before using the result.

 1async function safeGetActiveTab(): Promise<chrome.tabs.Tab | null> {
 2  try {
 3    const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
 4    if (!tab?.id) return null;      // no active tab or ID not accessible
 5    if (!tab.url && !tab.pendingUrl) {
 6      // Tab exists but URL is gated — still usable for scripting if activeTab granted
 7      console.warn("tab.url unavailable — check 'tabs' or host_permissions");
 8    }
 9    return tab;
10  } catch (err) {
11    // tabs.query itself can throw if the extension context is invalidated
12    console.error("tabs.query failed:", err);
13    return null;
14  }
15}

Execution context: Service worker or any extension page. The try/catch around tabs.query handles the edge case where the extension context is invalidated (e.g., during an update). Firefox throws a WebExtensionError in this state; Chrome throws a TypeError; both are caught by a plain catch.

Cross-browser variation

  • Chrome / Edge: lastFocusedWindow: true reliably returns the most recently focused normal or popup window. Minimizing all windows returns [].
  • Firefox: browser.tabs.query with lastFocusedWindow: true works identically. Firefox MV3 support for chrome.* aliases is partial — use browser.* for safety in Firefox-first extensions.
  • Safari: lastFocusedWindow: true works on macOS. On iOS, Safari extensions run in a single-window model; currentWindow and lastFocusedWindow are equivalent. tab.url is sometimes delayed until the tab’s status becomes "complete".

Verification

  1. Open the service-worker DevTools panel (chrome://extensions → “Service worker” → Inspect).
  2. In the console, run: chrome.tabs.query({ active: true, lastFocusedWindow: true }).then(console.log).
  3. Confirm the result is an array with one Tab object, and that url is populated (not undefined).
  4. If url is undefined, confirm "tabs" or a matching host permission is in manifest.json.
  5. If the array is empty, confirm you are not using currentWindow: true in the service-worker console.

FAQ

Why does my popup code work but the service-worker version returns an empty array?

The popup lives inside a window; the service worker does not. currentWindow: true resolves correctly in the popup and returns nothing in the service worker. Switch to lastFocusedWindow: true in the background context.

I declared "activeTab" but tab.url is still undefined. Why?

activeTab grants URL access only during the user-gesture window. If your code runs in an alarm handler, a storage.onChanged listener, or any path not triggered by a direct user action, the grant is not present. You need either "tabs" or a host permission for programmatic URL access.

Can I cache the tab ID to avoid repeated queries?

Avoid caching tab IDs in service-worker module scope — the worker can be evicted and restarted, clearing the variable. If you must persist a tab ID, write it to chrome.storage.session (Chrome 102+), which survives worker restarts within the same browser session.

tabs.query returns a tab but chrome.scripting.executeScript throws “Cannot access a chrome:// URL”. What happened?

The active tab is the browser’s own new-tab page, a chrome:// page, a chrome-extension:// page, or a PDF — none of which allow content-script injection. Always check tab.url before calling executeScript and bail out for internal browser URLs.

Other Core APIs & Cross-Browser Data Management Resources