How I built a tab manager in 136 KB with zero network calls
Chrome's native tab groups did not solve my problem. Neither did the memory saver. My problem was simpler and dumber: I keep forty tabs open because I am afraid of losing them, and forty tabs cost me about two gigabytes
Chrome's native tab groups did not solve my problem. Neither did the memory saver. My problem was simpler and dumber: I keep forty tabs open because I am afraid of losing them, and forty tabs cost me about two gigabytes of RAM and all of my attention.
So I built an extension that does one thing: it takes every tab in the current window, writes the URLs into local storage, closes the tabs, and shows them back as one scrollable column. One click in, one click out. It is 136,138 bytes across 69 files, uses four permissions, and makes zero network requests.
This post is the engineering part: the ordering that keeps you from losing data, the normalization layer, the undocumented field I had to support, and the MV3 service-worker traps that cost me a weekend.
The constraint list
I set the budget before writing the code, because a tab manager that itself eats memory is a joke:
| Constraint | Value |
|---|---|
| Permissions |
tabs, storage, contextMenus, alarms
|
| Network | none, no host permissions, no remote code |
| Package | 136 KB (53 locales included, ~5 KB each) |
| Largest file | 28.7 KB, the backups/stats UI |
| Storage |
chrome.storage.local, three keys |
| Framework | none. Plain DOM, no build step |
That last one matters more than it looks. With no bundler there is no shared module graph, and that single fact causes the first real bug.
Step 1: the collapse, in the right order
The popup does the whole operation. Naive version reads the tabs and closes them; that version loses data whenever anything in between fails. The working version is this:
const allTabs = await chrome.tabs.query({ currentWindow: true });
const activeTab = allTabs.find((t) => t.active);
const currentSpace =
activeTab && typeof activeTab.spaceId === "number" ? activeTab.spaceId : null;
const toSave = [];
const toClose = [];
for (const t of allTabs) {
if (t.pinned) continue;
if (currentSpace !== null && t.spaceId !== currentSpace) continue;
const url = typeof t.url === "string" ? t.url : "";
if (!storage.isRestorable(url)) continue;
const entry = { url, title: t.title || url, favIconUrl: t.favIconUrl || "" };
if (currentSpace !== null) entry.spaceId = currentSpace;
toSave.push(entry);
if (typeof t.id === "number") toClose.push(t.id);
}
// 1. write first
if (toSave.length) {
await storage.append(toSave);
try {
await storage.recordBatch(toSave.length);
} catch (e) {
console.warn("Failed to record stats:", e);
}
}
// 2. show the result
if (open) {
await chrome.windows.update(mgrTab.windowId, { focused: true });
await chrome.tabs.update(mgrTab.id, { active: true });
} else {
await chrome.tabs.create({ url: managerURL, active: true });
}
// 3. only now close
if (toClose.length) {
try {
await chrome.tabs.remove(toClose);
} catch (e) {
console.warn("Browser blocked tab closing:", e);
}
}
Three things here are deliberate.
Write before close. If storage.append rejects (quota, serialization, a corrupted key), the tabs stay open and the popup shows an error. Losing the list is recoverable; losing the tabs is not.
Open the manager before closing. If chrome.tabs.remove runs first and the manager fails to open, the user is staring at an empty window with no idea where their tabs went. Opening first means the destination exists before the sources disappear.
Never trust t.url to be a string. chrome:// pages, chrome-extension:// pages, and about:blank return empty URLs or URLs that cannot be reopened. Filtering happens once, in isRestorable, and the same predicate runs on every input path — see the next section.
Step 2: one normalization choke point
Every list of tabs in this extension comes from a different place: the live browser, an imported JSON file, an old storage format, a snapshot created by a service worker. Four writers, four chances to store null, a duplicate, or a chrome://newtab.
The fix is a single function that everything passes through:
const storage = {
KEY: "tabManagerData",
ALLOWED_PROTOCOLS: ["http:", "https:", "file:"],
isRestorable(url) {
if (typeof url !== "string" || !url) return false;
try {
return this.ALLOWED_PROTOCOLS.includes(new URL(url).protocol);
} catch {
return false;
}
},
normalize(list) {
if (!Array.isArray(list)) return [];
const seen = new Set();
const result = [];
for (const item of list) {
if (!item || typeof item !== "object") continue;
const url = typeof item.url === "string" ? item.url.trim() : "";
if (!this.isRestorable(url) || seen.has(url)) continue;
seen.add(url);
const entry = {
url,
title: typeof item.title === "string" && item.title.trim() ? item.title : url,
favIconUrl: typeof item.favIconUrl === "string" ? item.favIconUrl : ""
};
if (typeof item.spaceId === "number" && Number.isFinite(item.spaceId)) {
entry.spaceId = item.spaceId;
}
result.push(entry);
}
return result;
},
async save(tabs) {
await chrome.storage.local.set({ [this.KEY]: this.normalize(tabs) });
},
async load() {
const data = await chrome.storage.local.get(this.KEY);
return this.normalize(data[this.KEY]);
}
};
load runs normalize too, not just save. That costs a pass over the array on every read, and it means importing a file from a stranger — or reading data written by version 1.0.3 — can never put a broken entry into the renderer. Schema validation at the boundary, not at the call site.
The deduplication is a Set of URLs, not a set of titles. Two tabs with the same URL are the same tab; two tabs with the same title and different URLs are not. The popup uses the same idea for incrementals:
async append(newTabs) {
const incoming = this.normalize(newTabs);
if (!incoming.length) return 0;
const current = await this.load();
const known = new Set(current.map((t) => t.url));
const unique = incoming.filter((t) => !known.has(t.url));
if (unique.length) await this.save([...current, ...unique]);
return unique.length;
}
So collapsing the same window twice adds nothing instead of doubling the list.
Step 3: the undocumented field that made Yandex Browser work
Yandex Browser is Chromium, and MV3 extensions load in it unchanged. But it has a feature Chrome does not: tab groups as separate "spaces", and each tab object carries a numeric spaceId.
This field is not in Chrome's extension API documentation. chrome.tabs.query in Chrome returns undefined; in Yandex it returns the space number. If you ignore it, two things break: collapsing pulls tabs out of other spaces the user did not touch, and restoring drops every tab into whichever space happens to be active.
Because spaceId is non-standard, all the code is guarded by a type check rather than feature detection:
const currentSpace =
activeTab && typeof activeTab.spaceId === "number" ? activeTab.spaceId : null;
If it is null, the extension behaves exactly like it does in Chrome — whole window, no grouping. Restoring inverts the logic: group the saved entries by spaceId, then for each group find any live tab in that space, activate it (which switches the window to that space), and create the tabs after a short pause:
const groups = new Map();
for (const tab of tabsCache) {
const key = typeof tab.spaceId === "number" ? tab.spaceId : "none";
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(tab.url);
}
for (const [key, urls] of groups) {
let anchor = selfTab;
if (key !== "none") {
const live = await chrome.tabs.query({ windowId: winId });
const found = live.find(
(t) => t.spaceId === key && (!selfTab || t.id !== selfTab.id)
);
if (found) anchor = found;
}
if (anchor) {
await chrome.tabs.update(anchor.id, { active: true });
await sleep(350);
}
await createInChunks(urls);
}
The 350 ms pause is not superstition. Without it, chrome.tabs.create lands in the space that was active before the switch completed, and the user gets a pile of tabs in the wrong place.
Step 4: MV3 service worker traps
The daily backup runs from chrome.alarms, which means the writer is the service worker. Three things bit me.
No imports. With no bundler, background.js cannot import storage.js. The service worker therefore contains a second copy of createSnapshot. That is real duplication, and it has already drifted once (the worker version does not update the stats key). If I were starting over I would either add a five-line build step or put the storage module in a separate file loaded via importScripts.
Alarms have a minimum. periodInMinutes: 1440 is fine, but the first run has to be delayInMinutes, and the alarm must be recreated on both onInstalled and onStartup. Chrome clears alarms when the worker is discarded; it does not clear them across browser restarts, but users do uninstall/reinstall.
function ensureAlarm() {
chrome.alarms.create(ALARM_ID, { delayInMinutes: 5, periodInMinutes: 1440 });
}
chrome.runtime.onInstalled.addListener(() => { createContextMenu(); ensureAlarm(); });
chrome.runtime.onStartup.addListener(() => { createContextMenu(); ensureAlarm(); });
lastError is not optional. Creating a context menu item with an existing id throws, and because the callback is async the error surfaces as "Unchecked runtime.lastError" in the console — harmless until a reviewer sees it:
chrome.contextMenus.removeAll(() => {
chrome.contextMenus.create(
{ id: MENU_ID, title: chrome.i18n.getMessage("contextMenuOpen"), contexts: ["action"] },
() => { void chrome.runtime.lastError; }
);
});
Step 5: when storage says no
chrome.storage.local is 5 MB by default (10 MB on current Chrome), and a tab entry is roughly 200 bytes. That is twenty thousand tabs — until someone saves a snapshot of a window with 400 tabs, five times. Then set rejects.
The interesting decision is what to throw away. I keep the newest copy and drop the rest, on the theory that a recent backup is worth more than five stale ones:
const next = [snapshot, ...list].slice(0, SNAPSHOT_LIMIT);
try {
await this.saveSnapshots(next);
} catch {
await this.saveSnapshots(next.slice(0, 1));
}
A second guard sits above it: snapshots are skipped when the list is identical to the previous one, so an idle browser does not burn a slot every day:
const last = list[0];
if (last && last.tabs.length === tabs.length &&
last.tabs.every((t, i) => t.url === tabs[i].url)) {
return null;
}
Step 6: exporting a page that still works in five years
The export is the feature I use most, and it is the one that needed no backend. The extension generates a complete HTML document as a string — inline CSS with a prefers-color-scheme block, one list, a search input, an "open all" button — and hands it to the user as a Blob:
function downloadHtml(tabs, filename, filterQuery) {
const html = buildHtml(tabs, filterQuery);
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
saveBlob(blob, filename);
}
function saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
Two details worth stealing. Every interpolated string goes through esc, including the URL used as href — an exported file is opened from file://, where an injected javascript: link is a live attack. And each list item carries a precomputed data-search attribute:
`<li class="item" data-search="${esc((t.title || "") + " " + (t.url || "")).toLowerCase()}">`
so the live filter in the exported page is a loop over data-search.includes(query) and a hidden toggle. No index, no libraries, no reflow of a thousand anchors.
What the numbers actually look like
| Thing | Value |
|---|---|
| Package | 69 files, 136,138 bytes |
| JS | 4 files, ~52 KB unminified |
| CSS | 2 files, ~15 KB |
| Locales | 53 messages.json, 4.9–8.7 KB each |
| Network requests | 0 |
| Host permissions | 0 |
| Users | ~3,000 (organic, no paid acquisition) |
That last row is the honest part. The engineering is the easy half. A tab manager competes with a dozen free clones and with Chrome's own vertical tabs, so discoverability is a different problem entirely — and it is not solved by a smaller bundle. What did move the needle was localization: 53 locales cost about 4 KB of icons' worth of download and they roughly doubled installs per release in non-English markets.
Three things I would do differently
- Add a 20-line build step. Duplicating the storage module into the service worker is the kind of debt that silently diverges.
-
Never
innerHTML, even for trusted locale strings. I ended up writing a tiny parser that only honors<strong>so translated copy can carry emphasis without opening an injection hole. -
Ship
setUninstallURLon day one. An extension that fires and forgets cannot learn why 40% of installs disappear in a week.
The extension is Tabs in One on the Chrome Web Store — free, no account, no telemetry. The source for the parts quoted here is in the package if you want to diff it against your own implementation.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.