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.
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.
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."*://*.example.com/*").onUpdated, onActivated) are registered synchronously in the service worker module scope, not inside an async initializer.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.
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.
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.
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.
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.
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.
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.| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
tabs.query Promise API | Yes (MV3) | browser.tabs.query + native Promise | Yes |
tab.url without permission | undefined | undefined | undefined |
currentWindow in SW | Returns [] | Returns [] | Returns [] |
chrome.tabGroups | Chrome 89+ | Not supported | Not supported |
windows.create type: "panel" | Not supported | Limited MV3 support | Falls back to popup |
onUpdated SPA detection | Only status changes | URL changes visible with permission | Partial |
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.
currentWindow bugs and undefined URL fields.onUpdated and webNavigation.Track SPA navigation and page URL changes from a Manifest V3 service worker using tabs.onUpdated changeInfo.url, webNavigation events, debouncing, and host-permission gating.
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.