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.
Prerequisites checklist
Before calling any chrome.tabs method, verify:
"tabs"permission is declared inmanifest.jsonif you needtab.url,tab.title, ortab.pendingUrl. Without it those fields areundefined, 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.
7. Permissions and privacy for tab data
The tabs API sits on a permission boundary that shapes almost every design decision around it. Without the tabs permission or host access, an extension can list, create, move and close tabs, but the url, title and favIconUrl fields are omitted. With the permission, it can read every open tab’s address — which the install prompt describes, accurately, as reading browsing history.
Most extensions need far less than that. The activeTab permission grants access to the current tab’s URL only when the user invokes the extension, which covers “do something with this page” features completely. Host permissions for specific sites grant URLs for those sites only. The full tabs permission is justified for tab managers and session tools whose whole purpose is working with every open tab, and hard to justify for anything else. Choosing the narrowest option keeps the install prompt small and review straightforward, and the choice is explored in querying the active tab safely.
Whatever the extension can read, it should keep on the device. Tab URLs and titles are among the most sensitive data an extension can see; derived information — counts, domains grouped into categories — is almost always enough for any feature that reports or syncs.
8. Tab ids are short-lived identifiers
A tab id is valid only for the life of the tab and the browser session. It is reused after the tab closes, it means nothing after a restart, and it differs across devices. Code that stores tab ids in chrome.storage.local, syncs them, or keys long-lived data by them eventually attaches one tab’s state to another.
The rules are simple. Keep tab ids in chrome.storage.session, which is cleared when they stop being meaningful. Remove per-tab state in a tabs.onRemoved listener registered at the top level of the worker. And key anything that should outlive the tab by URL or origin instead — a user’s preference for a site, not for a tab that happens to be showing it.
9. Keeping tab listeners cheap
Tab events are among the busiest in the browser. A single page load fires tabs.onUpdated several times; restoring a session fires it hundreds of times in seconds; switching tabs fires onActivated constantly. A listener that does real work on each event keeps the service worker awake and adds latency everywhere.
Filter first: onUpdated accepts a filter object on current Chrome and Firefox, so the browser only wakes the worker for the properties and URLs you care about. Then debounce per tab, and coalesce large bursts into a single piece of work scheduled with an alarm, as described in throttling and debouncing high-frequency events. An extension that reacts to one event per page load, rather than seven, is measurably kinder to the browser it runs in.
10. Working with windows and focus
Window management adds one recurring surprise: focus. Activating a tab in a background window does not bring that window forward, so a “go to tab” feature must also focus the window. Opening a tab from the popup moves focus away and closes the popup, so any follow-up work must already be handed to the worker. And popup-type windows created with windows.create are not restored by session restore, so anything they display should be recoverable from storage. Treating focus as part of the operation, rather than an afterthought, removes most of the reports that a tab feature “did nothing”.
11. Designing tab features that respect the user’s workspace
Tabs and windows are the user’s workspace, arranged deliberately and often kept for days. An extension that rearranges that workspace without being asked — opening tabs on install or update, regrouping tabs automatically, moving the active tab, closing tabs it decides are unused — quickly becomes the extension users disable first. The tabs API makes all of these easy, which is exactly why restraint has to be designed in.
A useful rule is that the extension may change the workspace only in direct response to a user action, and should make the change reversible. Group tabs when the user asks, and offer to ungroup; close duplicates when the user clicks “clean up”, and list what was closed so it can be reopened through the sessions API. Opening a tab on first install is acceptable once, for onboarding; opening one on every update is not. Features that genuinely need to act automatically — a tab limit, an auto-suspender — should say so plainly in the listing and the options page, default to conservative behaviour, and make the automation easy to turn off.
The same restraint applies to windows. Creating popup-type windows for extension UI, resizing the user’s windows, or moving tabs between windows should always follow an explicit request. Users notice when their carefully arranged screen changes under them, and they rarely connect it to the extension responsible — which turns a small convenience feature into a mysterious annoyance that ends in an uninstall.
MV3 constraints to design around
- 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.
activeTabgrant is one-shot: it expires when the tab navigates. Do not cache the granted tab ID across navigations.tab.urlisundefinedwithout 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.currentWindowis meaningless in a service worker: always uselastFocusedWindow: truein background code.tabGroupsis Chrome/Edge only: guard every call with a feature check.tabs.sendMessagethrows if the content script is not injected: wrap calls in.catch(() => {})or check for content-script readiness first.
Cross-browser notes
| 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.
Further guides in this topic
The guides below go deeper into specific tabs api and window management problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- Handling Restricted URLs and Tab Permissions — Detect the tabs an MV3 extension may never touch — chrome://, the Web Store, PDF viewers and other extensions’ pages — and degrade gracefully instead of throwing.
- Opening and Tracking Extension Pages in Tabs — Open an extension page in a tab or popup window, avoid duplicate tabs, focus an existing one, and track its lifetime from an MV3 service worker that can be evicted at any moment.
- Capturing a Screenshot of the Visible Tab — Take screenshots in an MV3 extension with chrome.tabs.captureVisibleTab: activeTab versus host permissions, the two-per-second rate limit, PNG versus JPEG, cropping on OffscreenCanvas, full-page stitching and DPR.
- Creating Popup Windows with chrome.windows — Open standalone extension windows in MV3 with chrome.windows.create type popup: sizing and positioning on the current screen, reusing a single window, focusing, tracking close, and Firefox and Safari differences.
- Detecting and Closing Duplicate Tabs — Find and close duplicate tabs in an MV3 extension: normalising URLs, the tabs permission versus host access, choosing which tab to keep, preventing duplicates on creation, pinned and grouped tabs, and undo.
- Discarding and Muting Tabs to Save Memory — Free memory and silence noise from an MV3 extension: chrome.tabs.discard, autoDiscardable, idle-tab policies on alarms, protecting audible, pinned and form-filled tabs, muting with mutedInfo, and Firefox differences.
- Moving and Grouping Tabs Programmatically — Reorder, move and group tabs from an MV3 service worker: index semantics across windows, the tabGroups permission, colour and title limits, and keeping group state consistent.
- Restoring Window State After Restart — Reopen an MV3 extension’s window layout after a browser restart: what the browser restores for you, capturing bounds and tab sets, and why saved window ids are always stale.
- Waiting for a Tab to Finish Loading — Wait reliably for an MV3 tab to be ready before injecting or messaging: why status complete is not enough, listener-based waiting, timeouts, and handling tabs that never settle.
Related
- Querying the active tab safely — avoid
currentWindowbugs and undefined URL fields. - Detecting tab URL changes — track SPA navigation reliably with
onUpdatedand webNavigation. - Chrome Storage API & Sync — persist tab state across service-worker evictions.
- Message Passing Architecture — send messages between the service worker and tab contexts.
- Up to Core APIs & Cross-Browser Data Management.