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.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

An extension page opened in a tab — a dashboard, an onboarding flow, a full-screen editor — is the one surface that persists while the user works. The complication is that the code opening it lives in a service worker that is evicted between clicks, so “did I already open this?” cannot be answered from a variable. This guide is part of tabs API and window management.

Finding your own pages

The reliable way to know whether your page is already open is to ask the browser, not to remember. chrome.tabs.query accepts a URL pattern, and extension pages live on your own chrome-extension:// origin.

1const dashboardUrl = chrome.runtime.getURL("pages/dashboard.html");
2
3async function findDashboardTab() {
4  const [tab] = await chrome.tabs.query({ url: `${dashboardUrl}*` });
5  return tab ?? null;
6}

Execution context: the service worker. Querying by URL normally needs the tabs permission, but your own extension pages are always visible to you — no host permission is required for chrome-extension://<your-id>/*.

Open, or focus what is already openA click queries for an existing dashboard tab; if one exists it is focused along with its window, otherwise a new tab is created next to the active one.action.onClickedor a popup buttontabs.query({url})ask the browserFound?exactly one expectedfocus it, or create onetabs.update({active:true})focus the tabwindows.update({focused:true})raise its windowtabs.create({url, index})if nothing was found
The query replaces the tab id you cannot keep in memory across an eviction.

Step-by-step

1. Open or focus, in one function

 1async function openDashboard() {
 2  const existing = await findDashboardTab();
 3  if (existing) {
 4    await chrome.tabs.update(existing.id, { active: true });
 5    await chrome.windows.update(existing.windowId, { focused: true });
 6    return existing.id;
 7  }
 8
 9  const [active] = await chrome.tabs.query({ active: true, currentWindow: true });
10  const created = await chrome.tabs.create({
11    url: dashboardUrl,
12    index: active ? active.index + 1 : undefined,   // open beside the current tab
13  });
14  return created.id;
15}

Execution context: the service worker. Focusing the window as well as the tab matters — a tab activated in a background window is invisible, and the user concludes the button did nothing.

2. Open in a standalone popup window where it suits

A small tool window keeps the dashboard out of the tab strip and gives it a fixed size.

 1async function openToolWindow() {
 2  const existing = await chrome.tabs.query({ url: `${chrome.runtime.getURL("pages/tool.html")}*` });
 3  if (existing.length) {
 4    return chrome.windows.update(existing[0].windowId, { focused: true });
 5  }
 6  return chrome.windows.create({
 7    url: "pages/tool.html",
 8    type: "popup",          // no tab strip, no address bar
 9    width: 420,
10    height: 640,
11  });
12}

Execution context: the service worker. type: "popup" windows cannot be resized below the platform minimum and are not restored by session restore, so treat any state inside as recoverable from storage.

3. Pass parameters through the URL, not through memory

The worker may be evicted before the page loads, so a message sent immediately after tabs.create can arrive at nothing. Put the parameters in the URL.

1const url = new URL(chrome.runtime.getURL("pages/dashboard.html"));
2url.searchParams.set("view", "history");
3url.searchParams.set("jobId", jobId);
4await chrome.tabs.create({ url: url.href });

Execution context: the service worker builds the URL; the page reads it with new URLSearchParams(location.search) once loaded. This also makes the page linkable and reloadable, which a message-passed parameter never is.

4. Track the page’s lifetime without holding a tab id

 1chrome.tabs.onRemoved.addListener(async (tabId) => {
 2  const { dashboardTabId } = await chrome.storage.session.get("dashboardTabId");
 3  if (tabId !== dashboardTabId) return;
 4  await chrome.storage.session.remove("dashboardTabId");
 5  await chrome.action.setBadgeText({ text: "" });
 6});
 7
 8chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
 9  if (!tab.url?.startsWith(dashboardUrl)) return;
10  if (info.status === "complete") {
11    await chrome.storage.session.set({ dashboardTabId: tabId });
12  }
13});

Execution context: the service worker, with both listeners registered at the top level. chrome.storage.session is the right home for a tab id: it survives worker eviction and is cleared when the browser closes, which is exactly when the tab id stops meaning anything.

5. Survive the page being reloaded or navigated away

A user can navigate your dashboard tab to another site. Watch for it and drop the tracking rather than leaving a stale id.

1chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => {
2  if (info.status !== "loading" || !tab.url) return;
3  const { dashboardTabId } = await chrome.storage.session.get("dashboardTabId");
4  if (tabId === dashboardTabId && !tab.url.startsWith(dashboardUrl)) {
5    await chrome.storage.session.remove("dashboardTabId");
6  }
7});

Execution context: the service worker. The loading status fires before the new document exists, which is the last moment your page could have saved anything — the constraint behind managing extension state across reloads.

Tab, popup window or side panel for a long-lived surfaceComparison of an extension page in a tab, a standalone popup window and the side panel on persistence, sizing, session restore and cross-browser availability.PropertyExtension page in a tabPopup windowSide panelSurvives navigation elsewhereUntil navigatedYesYesRestored by session restoreYesNoPer browserUser can resizeFull windowYes, with minimumsWidth onlyBookmarkable / linkableYesNoNoAvailable off ChromeEverywhereEverywhereChrome only
A tab is the only one of the three that every engine supports and the user can bookmark.

Keeping a dashboard and a worker in agreement

An extension page open in a tab is a long-lived context sitting next to a worker that is mostly evicted, and the two will disagree unless one of them is authoritative. Making the storage authoritative rather than either context is what keeps the disagreement from mattering.

The page renders from storage and reacts to changes; the worker writes to storage and never pushes UI state directly:

 1// dashboard.js
 2async function render() {
 3  const { job, settings } = await chrome.storage.local.get(["job", "settings"]);
 4  paint(job, settings);
 5}
 6
 7chrome.storage.onChanged.addListener((changes, area) => {
 8  if (area !== "local") return;
 9  if ("job" in changes || "settings" in changes) render();
10});
11
12render();

Execution context: the dashboard page, which owns its own event loop and lives until the user closes the tab. No message round trip is involved, so the page renders correctly whether the worker is running or not — the point made in why the popup closes and how to work with it applied to a longer-lived surface.

There is one case where the page should talk to the worker: starting work. A message is the right way to say “begin the export”, because the worker owns the job. The reply should be an acknowledgement, not the result — the result arrives through storage like everything else.

The second agreement problem is duplicate work. Two dashboard tabs, or a dashboard and a side panel, can each decide to start the same job. Guard with a claim in storage rather than with a flag in either context:

1async function claimJob(name) {
2  const { claims = {} } = await chrome.storage.session.get("claims");
3  if (claims[name] && Date.now() - claims[name] < 30_000) return false;
4  claims[name] = Date.now();
5  await chrome.storage.session.set({ claims });
6  return true;
7}

Execution context: the service worker, called at the start of the job handler. The timestamp doubles as a lease: a claim from a worker that was evicted mid-job expires after thirty seconds and the next request can proceed, which is preferable to a stuck flag that requires a restart to clear.

Storage as the single source of truthTwo extension pages and a service worker all read from and write to chrome.storage, with storage.onChanged as the only notification path between them.Dashboard tabrenders from storageSide panelrenders from storagePopuprenders from storageall read the same keys, all listen to onChangedchrome.storagethe authorityService workerwrites resultsonChangedthe only push
No context pushes state to another — every surface renders from the same store and reacts to the same event.

Cross-browser variation

  • Chrome / Edge: chrome.tabs.query matches your own extension pages without the tabs permission. windows.create({ type: "popup" }) produces a chromeless window; the exact minimum size is platform-dependent.
  • Firefox: identical behaviour under browser.tabs and browser.windows. Firefox additionally supports browser.tabs.create({ openerTabId }), which makes the new tab close back to its opener.
  • Safari: tab and window creation work, but popup-type windows are more constrained and may open as ordinary windows. Query results can lag immediately after create, so use the id the create promise resolves with rather than re-querying.
  • All three: an extension page in a tab keeps running when the worker is evicted. It is a legitimate place to hold state the worker cannot — but it disappears the moment the user closes it, so persist anything that matters.

Verification

  1. Click your action twice and confirm exactly one tab exists:
1(await chrome.tabs.query({ url: `${chrome.runtime.getURL("pages/dashboard.html")}*` })).length;
2// 1

Execution context: the service worker console. A result of 2 means the query pattern did not match — check for a trailing slash or a query string the pattern excludes.

  1. Move the dashboard tab to a second window, click the action, and confirm that window is raised rather than a new tab opened.
  2. Stop the worker from chrome://extensions, click the action, and confirm the open-or-focus logic still works from a cold start.
  3. Close the tab and confirm the badge clears and dashboardTabId is removed from session storage.

FAQ

Should I use chrome.tabs.create or window.open from a popup?

Use chrome.tabs.create. window.open from a popup document is subject to the popup’s own lifetime and can be blocked; the tabs API is explicit about where the tab lands and returns its id.

Can I reuse a tab the user already navigated away from?

You can — chrome.tabs.update(tabId, { url }) will navigate it back — but it is rarely welcome. The user navigated away deliberately; open a new tab instead.

How do I reopen the page after a browser restart?

You generally should not. If the page was mid-task, store the task state and offer to resume from the popup, rather than opening a tab the user did not ask for on every startup.

Other Core APIs & Cross-Browser Data Management Resources