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.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

A tab manager’s most requested feature is “close duplicates”: the user has the same documentation page open four times across three windows and wants one. It sounds trivial — group tabs by URL, close all but one — until the details arrive. URLs that differ only by a tracking parameter or a fragment are duplicates to a human but not to a string comparison. Which copy should survive: the active one, the pinned one, the one in a tab group, the oldest? Reading every tab’s URL needs a permission that shows a “Read your browsing history” warning. And closing tabs is destructive, so there must be an undo. This guide handles all of it. It belongs to tabs API and window management.

What makes two tabs duplicates

Two tabs are duplicates when they show the same content as far as the user is concerned. That is a judgement encoded as URL normalisation: remove the fragment (#section-2 is the same page), drop known tracking parameters (utm_*, fbclid, gclid), sort the remaining query parameters, lower-case the host, and strip a trailing slash. Some sites encode real state in fragments — single-page apps with hash routing — so the normalisation should be conservative by default and configurable. Reading URLs for all tabs requires the tabs permission (or host access to every site, which is worse); with only activeTab, the extension cannot see other tabs’ URLs at all.

From open tabs to duplicate groupsQuery all tabs, normalise each URL by removing fragments and tracking parameters and sorting the query, group tabs by normalised URL, choose a keeper in each group, and close the rest with an undo record.tabs.query({})needs "tabs"normalise(url)fragment, utm_*, ordergroup by keyMap<key, tabs>for groups larger than onechoose keeperactive > pinned > grouped > oldestrecord undourls + positionstabs.remove(rest)close duplicates
Normalisation decides what 'same page' means; the keeper rule decides what survives.

Step-by-step: safe duplicate cleanup

1. Request the right permission

1{ "permissions": ["tabs", "storage", "sessions"] }

Execution context: the manifest. tabs exposes url, title and favIconUrl on every tab and shows “Read your browsing history” at install — unavoidable for a tab manager, and worth justifying in the listing. sessions enables restoring closed tabs for undo. If duplicate cleanup is a secondary feature, put tabs in optional_permissions and request it when the user first uses the feature.

2. Normalise URLs conservatively

 1// dedupe.js
 2const TRACKING = /^(utm_[a-z]+|fbclid|gclid|mc_eid|mc_cid|ref_src|igshid)$/i;
 3
 4export function normalise(raw, { keepHash = false } = {}) {
 5  let u;
 6  try { u = new URL(raw); } catch { return raw; }
 7  if (!/^https?:$/.test(u.protocol)) return raw;                  // leave other schemes alone
 8  if (!keepHash) u.hash = "";
 9  const params = [...u.searchParams].filter(([k]) => !TRACKING.test(k)).sort(([a], [b]) => a.localeCompare(b));
10  u.search = new URLSearchParams(params).toString();
11  u.hostname = u.hostname.toLowerCase();
12  if (u.pathname.length > 1 && u.pathname.endsWith("/")) u.pathname = u.pathname.slice(0, -1);
13  return u.href;
14}

Execution context: a pure module, testable in Node. Non-HTTP URLs — chrome://, file://, extension pages — are compared verbatim. Hash routing apps (#/inbox versus #/settings) are real duplicates only if the hash matches; offer keepHash per site or detect #/ patterns. Keep the tracking list short and well-known; aggressive normalisation merges pages that differ.

URL differences and whether they mean a duplicateCommon URL differences — fragment, tracking parameters, query order, trailing slash, host case, hash routes and meaningful query parameters — and whether normalisation should treat them as the same page.DifferenceSame page?Normalise#section anchorYesDrop hashutm_*, fbclidYesDrop paramQuery orderYesSortTrailing slash, host caseUsuallyStrip / lower#/route (SPA)NoKeep hash?id=42 vs ?id=43NoKeep
Merge differences that never change content; keep the ones that do.

3. Group and choose a keeper

 1export function findDuplicates(tabs, opts) {
 2  const groups = new Map();
 3  for (const t of tabs) {
 4    if (!t.url) continue;
 5    const key = normalise(t.url, opts);
 6    groups.set(key, [...(groups.get(key) ?? []), t]);
 7  }
 8  const plan = [];
 9  for (const [key, list] of groups) {
10    if (list.length < 2) continue;
11    const keeper = [...list].sort(rank)[0];
12    plan.push({ key, keeper, close: list.filter((t) => t.id !== keeper.id) });
13  }
14  return plan;
15}
16
17const rank = (a, b) =>
18  Number(b.active) - Number(a.active) ||
19  Number(b.pinned) - Number(a.pinned) ||
20  Number(b.groupId !== -1) - Number(a.groupId !== -1) ||
21  (a.lastAccessed ?? 0) - (b.lastAccessed ?? 0) ? 1 : -1;

Execution context: a pure function over tab objects. The ranking keeps the tab the user is most invested in: the active tab, then pinned, then tabs inside a group, then the most recently used (lastAccessed, Chrome 121+). Never close pinned tabs or tabs playing audio unless the user explicitly asks — they are deliberate.

4. Preview before closing

1// popup.js
2const tabs = await chrome.tabs.query({});
3const plan = findDuplicates(tabs, { keepHash: false });
4renderPreview(plan.map((g) => ({ title: g.keeper.title, count: g.close.length })));
5confirmBtn.hidden = plan.length === 0;

Execution context: the popup. Showing “12 duplicates across 5 pages” with titles and favicons, before closing anything, catches normalisation mistakes and gives the user confidence. Querying from the popup is fine because it has the same permissions as the worker.

Close duplicates with undoThe popup previews the plan; the user confirms; the worker records URLs and positions, closes duplicate tabs, and offers undo; undo restores them with sessions.restore or by recreating tabs.PopupService workerBrowser tabspreview: 12 duplicatesconfirmrecord undo {url, windowId, index}tabs.remove([…])undo (within 30 s)recreate at original positions
Destructive actions on tabs need a preview and an undo.

5. Close with an undo record

 1// sw.js
 2export async function closeDuplicates(plan) {
 3  const toClose = plan.flatMap((g) => g.close);
 4  const undo = toClose.map((t) => ({ url: t.url, windowId: t.windowId, index: t.index, pinned: t.pinned, groupId: t.groupId }));
 5  await chrome.storage.session.set({ lastDedupe: { at: Date.now(), undo } });
 6  await chrome.tabs.remove(toClose.map((t) => t.id));
 7}
 8
 9export async function undoDedupe() {
10  const { lastDedupe } = await chrome.storage.session.get("lastDedupe");
11  for (const t of lastDedupe?.undo ?? []) {
12    const created = await chrome.tabs.create({ url: t.url, windowId: t.windowId, index: t.index, active: false, pinned: t.pinned })
13      .catch(() => chrome.tabs.create({ url: t.url, active: false }));       // original window may be gone
14    if (t.groupId !== -1) chrome.tabs.group({ tabIds: created.id, groupId: t.groupId }).catch(() => {});
15  }
16  await chrome.storage.session.remove("lastDedupe");
17}

Execution context: the service worker. Recreating tabs restores URLs and positions but not page state or history; chrome.sessions.restore can bring back recently closed tabs with their history, but it restores the most recent closes in order and may pick up unrelated tabs the user closed in between. Recreating is predictable; mention in the UI that undo reopens the pages.

6. Prevent duplicates as they are created

1chrome.tabs.onUpdated.addListener(async (tabId, change, tab) => {
2  if (!change.url || !(await prefEnabled("preventDuplicates"))) return;
3  const key = normalise(change.url);
4  const existing = (await chrome.tabs.query({})).find((t) => t.id !== tabId && t.url && normalise(t.url) === key);
5  if (!existing) return;
6  await chrome.tabs.update(existing.id, { active: true });
7  await chrome.windows.update(existing.windowId, { focused: true });
8  await chrome.tabs.remove(tabId);
9});

Execution context: the service worker. Switching to the existing tab instead of opening a new one is popular but surprising when unexpected — keep it opt-in, skip it for tabs opened in the background (the user may want a second copy to compare), and never apply it to pinned or grouped targets without asking.

7. Respect window and group boundaries if the user wants

Some users keep the same page open in a “Research” window and a “Writing” window deliberately. Offer a scope option — “this window only” — that filters tabs.query({ currentWindow: true }), and treat tabs in different tab groups as distinct if the user prefers.

Common mistakes

  • Comparing raw URLs. Fragments and tracking parameters make obvious duplicates look different.
  • Aggressive normalisation. Dropping all query parameters merges different pages.
  • Closing pinned or audible tabs. They were kept on purpose.
  • No preview or undo. Closing the wrong tab loses work.
  • Requesting tabs for a minor feature. Make it optional.

Cross-browser variation

  • Chrome / Edge: tabs permission for URLs; lastAccessed from Chrome 121; tab groups via chrome.tabGroups.
  • Firefox: same tabs model; lastAccessed long supported; Firefox containers mean the same URL in two containers may be deliberately separate — compare cookieStoreId too.
  • Safari: tabs permission with Safari’s per-site prompts; tab groups are not exposed to extensions.

Verification

  1. Open the same page with and without ?utm_source=x and with an anchor; confirm they group together.
  2. Open two different SPA routes with hash URLs; confirm they stay separate when keepHash is on.
  3. Close duplicates and confirm the active and pinned tabs survive.
  4. Undo and confirm the closed tabs reopen in their original windows and positions.

FAQ

Can I detect duplicates without the tabs permission?

Only for tabs on sites you have host access to. A general-purpose deduplicator needs tabs.

Should duplicates across incognito and normal windows merge?

No. Treat incognito tabs separately — and only touch them if the extension is allowed in incognito.

What about discarded tabs?

They have URLs and can be closed like any other. Prefer keeping a loaded copy over a discarded one.

Other Core APIs & Cross-Browser Data Management Resources