Tabs API & Window Management

Control browser tabs and windows in Manifest V3: tabs.query, tabs.create, tab groups, onUpdated/onActivated events, activeTab permission, and cross-browser quirks.

Without deterministic tab control, an extension cannot reliably read the user’s current page, respond to navigation, or open coordinated windows — and doing it wrong under Manifest V3 means either missing the activeTab grant window or querying stale context IDs. This guide is part of Core APIs & Cross-Browser Data Management. The most frequent failure point is querying the active tab safely from a service worker that woke up without a user gesture.

Tabs API data flow in a Manifest V3 extensionThe service worker and popup both call chrome.tabs APIs; events flow back from the browser tab through onUpdated and onActivated listeners registered at top level in the service worker.Service Workerbackground contextPopupUI contextchrome.tabsquery · create · updateremove · sendMessageBrowser Tabsreal page stateEventsonUpdatedonActivatedpush events back to listeners

Prerequisites checklist

Before calling any chrome.tabs method, verify:

  • "tabs" permission is declared in manifest.json if you need tab.url, tab.title, or tab.pendingUrl. Without it those fields are undefined, not an error.
  • "activeTab" permission is declared if you only need access to the currently focused tab after a user gesture — this is the narrower, store-preferred option.
  • Host permissions are declared for any tab whose URL you need to read programmatically without a gesture (e.g., "*://*.example.com/*").
  • Your top-level event listeners (onUpdated, onActivated) are registered synchronously in the service worker module scope, not inside an async initializer.

1. Declare permissions

The choice between "tabs" and "activeTab" matters at review time. "tabs" is a sensitive permission that triggers a Chrome Web Store warning and gives permanent URL access to every tab. "activeTab" grants one-shot access only during a user gesture and is invisible to the user in the install dialog.

1{
2  "manifest_version": 3,
3  "name": "Tab Manager",
4  "permissions": ["tabs", "activeTab"],   // "tabs" for URL access; "activeTab" for gesture-gated injection
5  "host_permissions": ["*://*.example.com/*"]  // required for scripting without a gesture
6}

Execution context: Root manifest.json, parsed at install time by the browser. Chrome and Edge treat "tabs" as a sensitive permission shown in the permissions dialog. Firefox accepts both but notes that "tabs" is optional in many MV2→MV3 compat shims. Safari enforces "activeTab" strictly; a cold service-worker wake without a gesture will not receive the grant.

2. Query and filter tabs

The most common source of bugs is calling tabs.query with currentWindow: true from a service worker. The service worker has no concept of “current window” — it runs outside any window context — so currentWindow: true silently returns an empty array. Use lastFocusedWindow: true instead, or pass the window ID explicitly. For the full picture of why this fails, see querying the active tab safely.

 1// Correct: from a service worker, use lastFocusedWindow
 2async function getActiveTab(): Promise<chrome.tabs.Tab | undefined> {
 3  const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
 4  return tab;
 5}
 6
 7// Correct: from a popup, currentWindow is fine because the popup IS inside a window
 8async function getActiveTabFromPopup(): Promise<chrome.tabs.Tab | undefined> {
 9  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
10  return tab;
11}

Execution context: getActiveTab runs in the service worker background context where no DOM or window object exists. getActiveTabFromPopup runs in the popup’s renderer process. Both return undefined when the query matches no tab — always guard the result. Firefox returns the same browser.tabs.Tab shape; Safari may omit tab.url even with "tabs" declared if the tab is still loading.

Tabs have additional queryable fields: audible, discarded, muted, pinned, status. Filter by status: "complete" when you need a fully-loaded DOM, though note that SPAs may never fire a second status: "complete" after the initial load — see detecting tab URL changes for the right approach there.

3. Create and update tabs

tabs.create and tabs.update are the standard ways to open new pages or redirect existing ones. Both return a Promise<chrome.tabs.Tab> in MV3.

 1// Open a new tab
 2async function openSettingsTab(): Promise<chrome.tabs.Tab> {
 3  return chrome.tabs.create({
 4    url: chrome.runtime.getURL("options/index.html"),
 5    active: true,
 6  });
 7}
 8
 9// Update an existing tab's URL
10async function redirectTab(tabId: number, url: string): Promise<void> {
11  await chrome.tabs.update(tabId, { url, active: true });
12}
13
14// Close one or more tabs
15async function closeTabs(tabIds: number[]): Promise<void> {
16  await chrome.tabs.remove(tabIds);
17}

Execution context: Service worker or any extension page with the "tabs" permission. chrome.runtime.getURL translates a path relative to the extension root into a fully-qualified chrome-extension:// URL — required for tabs.create when pointing at your own pages. Firefox and Edge accept the same API; Safari’s tabs.update may throttle rapid successive calls on iOS.

4. React to tab lifecycle events

Register listeners at the top level of the service worker. Any listener registered inside a Promise callback, chrome.runtime.onInstalled, or an async initializer will be missed during service-worker cold starts.

 1// Register at module top-level — never inside an async or event handler
 2chrome.tabs.onActivated.addListener(({ tabId, windowId }) => {
 3  // Tab focus changed — fetch fresh state
 4  chrome.tabs.get(tabId).then((tab) => {
 5    // tab.url requires "tabs" permission
 6    console.log("Activated:", tab.url);
 7  }).catch(() => {
 8    // Tab may already be closed by the time the callback runs
 9  });
10});
11
12chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
13  // changeInfo.url is populated only when the URL actually changes
14  if (!changeInfo.url) return;
15  console.log("URL changed:", changeInfo.url);
16});

Execution context: Top-level service worker. chrome.tabs.onActivated fires when the user switches tabs or when a new tab becomes active programmatically. chrome.tabs.onUpdated fires multiple times per navigation — filter on changeInfo fields you actually need to avoid redundant processing. Firefox fires onUpdated reliably for both full-page loads and SPA pushState changes when the tab has the "tabs" permission; Chrome requires webNavigation for SPA changes — covered in detecting tab URL changes.

5. Windows API

The windows API complements the tabs API: tabs live inside windows. Use chrome.windows.create to open a standalone extension popup window (distinct from the browser-action popup), and chrome.windows.getLastFocused to find which window currently has keyboard focus.

 1// Open a detached tool window
 2async function openToolWindow(): Promise<chrome.windows.Window> {
 3  const win = await chrome.windows.create({
 4    url: chrome.runtime.getURL("tool/index.html"),
 5    type: "popup",
 6    width: 640,
 7    height: 480,
 8    focused: true,
 9  });
10
11  // Persist the window ID so we can focus it if opened again
12  if (win.id) {
13    await chrome.storage.local.set({ toolWindowId: win.id });
14  }
15
16  // Clean up persisted ID when the window closes
17  chrome.windows.onRemoved.addListener(function onRemoved(id) {
18    if (id === win.id) {
19      chrome.storage.local.remove("toolWindowId");
20      chrome.windows.onRemoved.removeListener(onRemoved);
21    }
22  });
23
24  return win;
25}

Execution context: Service worker. The created window runs in a separate renderer process; the service worker cannot access its document. All inter-context communication must use message passing architecture or Chrome Storage API & Sync. Safari does not support type: "panel" or type: "detached_panel" — both fall back to "popup". Firefox MV3 supports type: "panel" only in limited contexts.

6. Tab groups

Chrome 89+ introduced chrome.tabGroups for grouping tabs visually. This API requires the "tabGroups" permission and is not yet part of the WebExtensions standard — Firefox and Safari do not support it.

 1// Group a set of tab IDs into a named, colored group (Chrome only)
 2async function groupTabs(tabIds: number[], title: string): Promise<void> {
 3  if (!chrome.tabGroups) return; // not available in Firefox/Safari
 4
 5  const groupId = await chrome.tabs.group({ tabIds });
 6  await chrome.tabGroups.update(groupId, {
 7    title,
 8    color: "blue",
 9    collapsed: false,
10  });
11}

Execution context: Service worker on Chrome 89+ or Edge 89+. The call is a no-op guard for Firefox and Safari where chrome.tabGroups is undefined. Do not ship tab-group features as core functionality in cross-browser extensions.

MV3 Constraints box

  • No persistent background: the service worker dies after ~30 s of inactivity. Never store a tab ID in a module-scope variable — re-query on each invocation.
  • activeTab grant is one-shot: it expires when the tab navigates. Do not cache the granted tab ID across navigations.
  • tab.url is undefined without permission: "tabs" or a matching host permission is required to read the URL. The tab object is returned regardless; only URL-related fields are gated.
  • currentWindow is meaningless in a service worker: always use lastFocusedWindow: true in background code.
  • tabGroups is Chrome/Edge only: guard every call with a feature check.
  • tabs.sendMessage throws if the content script is not injected: wrap calls in .catch(() => {}) or check for content-script readiness first.

Cross-browser notes

FeatureChrome / EdgeFirefoxSafari
tabs.query Promise APIYes (MV3)browser.tabs.query + native PromiseYes
tab.url without permissionundefinedundefinedundefined
currentWindow in SWReturns []Returns []Returns []
chrome.tabGroupsChrome 89+Not supportedNot supported
windows.create type: "panel"Not supportedLimited MV3 supportFalls back to popup
onUpdated SPA detectionOnly status changesURL changes visible with permissionPartial

Firefox uses browser.tabs with native Promises. A thin namespace shim (const tabs = (typeof browser !== "undefined" ? browser : chrome).tabs) keeps your call sites identical across vendors.

This guide covers the core surface. Related deep-dives: querying the active tab safely walks through the currentWindow vs lastFocusedWindow trap in detail, and detecting tab URL changes covers reliable SPA navigation tracking.