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.
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.
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.
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.
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
tabsfor a minor feature. Make it optional.
Cross-browser variation
- Chrome / Edge:
tabspermission for URLs;lastAccessedfrom Chrome 121; tab groups viachrome.tabGroups. - Firefox: same
tabsmodel;lastAccessedlong supported; Firefox containers mean the same URL in two containers may be deliberately separate — comparecookieStoreIdtoo. - Safari:
tabspermission with Safari’s per-site prompts; tab groups are not exposed to extensions.
Verification
- Open the same page with and without
?utm_source=xand with an anchor; confirm they group together. - Open two different SPA routes with hash URLs; confirm they stay separate when
keepHashis on. - Close duplicates and confirm the active and pinned tabs survive.
- 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.
Related
- Moving and grouping tabs programmatically — organising what remains.
- Discarding and muting tabs to save memory — another tab-hygiene feature.
- Using the topSites and sessions APIs — restoring closed tabs.
- Tabs API and window management — the parent topic.