A two-pass restore algorithm that preserves global tab order, duplicate URLs, pinned tabs, group metadata, and the active tab — and reports partial failures.
Disclosure: I build Tabwell, a local-first Chrome session manager.
My first restore implementation passed the only assertion I had: every saved URL reopened. Then I used it on a mixed grouped-and-ungrouped session that the test did not cover.
I'd saved a window with a pinned Gmail tab, a research group, a build group, and a few loose tabs in between. Hit restore. Every URL opened. And the window was still wrong — the loose tabs were bunched at the end instead of sitting between the groups, the duplicate URLs could no longer be distinguished during regrouping, and the intended active tab was lost. Nothing crashed. No error anywhere. The session was just... not my session anymore. That's the moment this post is about: a browser session can look successfully restored and still be damaged.
Here's a layout that breaks naive implementations:
[pinned tab]
→ [Research: A, B]
→ [ungrouped C]
→ [Build: D, D]
→ [ungrouped E]
The two D tabs intentionally contain the same URL.
The tempting way to restore this is one group at a time, then the ungrouped tabs at the end. All URLs reopen, so a simple "did everything open" test passes. But the tab strip no longer matches the original global order — C is no longer between the groups, and, if URL is used as the mapping key, the two Ds collapse into one mapping entry.
While building restore for Tabwell, I eventually stopped thinking of it as "reopen a list of URLs" and started treating it as reconstruction of a small object graph.
What does "restored" actually mean?
Before rewriting the algorithm I had to pin down the contract, because "it works" was clearly not a spec.
For one saved Chrome window, a successful restore should preserve:
- the global order of tabs;
- separate instances of duplicate URLs;
- pinned state;
- the intended active tab;
- group membership;
- each group's title, color, and collapsed state;
- the original window state, where Chrome allows it.
It should also report partial success honestly. If nine of ten tabs open, a generic "success" is a lie. But throwing the whole result away isn't obviously better either — a window with nine perfectly good tabs already exists on the user's screen.
And some things a URL-based snapshot simply cannot bring back: text typed into forms, scroll position, unsaved editor state, application state hiding behind an unchanged URL, anything that happened after the snapshot. I treat that boundary as a product constraint, not just a code detail, because users will otherwise assume more.
Runtime IDs are references, not identities
Chrome gives every tab and tab group a numeric ID. Those IDs identify objects in the current browser runtime — a freshly restored window gets entirely new ones.
A simplified snapshot model:
type SavedGroup = {
savedRef: number;
title: string;
color: chrome.tabGroups.ColorEnum;
collapsed: boolean;
};
type SavedTab = {
id: number;
url: string;
index?: number;
pinned: boolean;
active?: boolean;
savedGroupRef?: number;
};
type SavedWindow = {
windowState: chrome.windows.windowStateEnum;
groups: SavedGroup[];
tabs: SavedTab[];
};
savedGroupRef is a pedagogical name — in Tabwell's actual stored schema, the captured numeric group ID serves as the relation between a saved tab and its saved group. The name doesn't matter; the semantics do. That number means something inside the snapshot only. It is never treated as the ID of the future Chrome group.
During restoration, chrome.tabs.group() hands back a brand-new group ID, and that new ID is what goes into chrome.tabGroups.update().

The duplicate Gmail URLs still have separate saved tab IDs.
Why the restore needs two passes
The algorithm that finally held up:
- Materialize every saved tab in global order. Tabwell reuses the new window's default blank tab for the first successfully restored item, then creates the remaining tabs.
- Record the mapping from saved tab IDs to new Chrome tab IDs.
- Build the groups from those new tab IDs.
- Apply group metadata.
- Restore the intended active tab last.
Pass one: create tabs
Sort the selected tabs by their captured index, falling back to snapshot order. A newly created Chrome window already contains one blank tab, so the production path reuses it for the first successfully restored tab. The helper in this simplified loop hides that bookkeeping and returns either the new runtime tab ID or undefined:
const orderedTabs = [...savedWindow.tabs].sort(compareCapturedOrder);
const newTabIdBySavedTabId = new Map<number, number>();
const failedUrls: string[] = [];
for (const savedTab of orderedTabs) {
const newTabId = await createRestoredTab(savedTab, windowId);
if (newTabId === undefined) {
failedUrls.push(savedTab.url);
continue;
}
newTabIdBySavedTabId.set(savedTab.id, newTabId);
}
The map is keyed by the saved tab ID, not the URL. Key by URL and two saved entries collapse into one mapping entry, so a later step can no longer assign group membership and active state to the correct tab. Two tabs with the same URL are still two distinct browser objects.
Creating everything in global order also dodges a subtler grouping problem: Chrome groups are contiguous. If you restore group A, then group B, then the ungrouped tabs, you have already destroyed any layout where grouped and ungrouped tabs were interleaved. Fixing that afterwards requires a separate tab-moving pass, so it is simpler not to destroy the order in the first place.
Pass two: create groups
Once the tabs exist, each saved group is rebuilt from the tab IDs that were actually created:
let failedGroups = 0;
for (const savedGroup of savedWindow.groups) {
const newTabIds = orderedTabs
.filter((tab) => tab.savedGroupRef === savedGroup.savedRef)
.map((tab) => newTabIdBySavedTabId.get(tab.id))
.filter((id): id is number => id !== undefined);
if (newTabIds.length === 0) {
continue;
}
try {
const newGroupId = await chrome.tabs.group({
tabIds: newTabIds as [number, ...number[]],
createProperties: { windowId },
});
await chrome.tabGroups.update(newGroupId, {
title: savedGroup.title,
color: savedGroup.color,
collapsed: savedGroup.collapsed,
});
} catch {
failedGroups += 1;
}
}
After grouping settles, the saved active tab gets reactivated through the saved-ID-to-new-ID map. Grouping can change which tab Chrome leaves active, so the intended tab is activated only after all group operations finish.
Partial restore is a real result
Once a new window exists, restoration stops being an all-or-nothing transaction, whether you like it or not.
A tab can fail when Chrome rejects the create or update call, or when a create call returns no tab ID. A later page-load failure is outside this restore result. A group operation can fail after its tabs already opened. Instead of hiding all that behind one generic exception, the restore path returns structure:
type RestoreResult = {
windowId: number;
requested: number;
restored: number;
failed: number;
failedUrls: string[];
failedGroups?: number;
};
Now the UI can say "9 of 10 tabs restored" and point at the URL that failed.
It also makes a dangerous retry pattern visible. If the caller assumes the whole operation failed and just runs it again, the second attempt duplicates every tab that had already opened. The caller can instead decide whether to keep, remove, or explicitly retry the partial window.
URL handling needs its own boundary, and it isn't one-size-fits-all. For imported third-party session files, a strict http:/https: allowlist is reasonable. For snapshots captured from the user's own browser, Tabwell does not pre-filter chrome: or file: URLs, although Chrome may refuse or rewrite them at restore time. It still rejects dangerous schemes such as javascript:, data:, and vbscript: before any create or update call.
Manifest V3 changes the persistence assumptions
An MV3 service worker is not a permanent process. Anything held only in memory can be gone between two events. Tabwell therefore treats persistence as the save boundary: a save counts as successful only after the snapshot reaches local IndexedDB.
The normal restore flow is not a resumable transaction journal. After partial success, it reports what happened instead of retrying automatically.
Test invariants, not just the happy path
"Three URLs reopened" is not an interesting test. The interesting ones encode the invariants:
- grouped and ungrouped tabs interleaved in one window;
- two tabs with exactly the same URL;
- a pinned ungrouped tab;
- the intended active tab restored after grouping;
- a separate group whose collapsed state must survive;
- groups with different titles and colors;
- restoring only one selected group;
- one URL rejected while the rest succeed;
- a group operation failing after its tabs were created;
- an empty selection, or one with no matching tabs, rejected before any window is created.
And the assertions have to inspect the real restored window — tab order, group membership, metadata, pinned state, active tab. Counting opened URLs is precisely the test that passed while my first implementation was scrambling everything.
The three rules I'm keeping
The implementation now follows three constraints:
- Runtime IDs can serve as references inside a snapshot, but they are not identities for future runtime objects.
- Rebuild global ordering before rebuilding the relationships that can move those objects around.
- Once an operation has irreversible partial success, return a structured result — don't flatten it into an exception and a suggestion to blindly retry.
I implemented this restore path in Tabwell.
A question for you: once a manual restore window exists and one tab fails, what would you actually prefer — keep the partial window, roll it back, or build a resumable restore journal? For ordinary manual restore I keep the partial window and report it; automatic crash recovery uses a separate rollback path.
Disclosure: I used AI to help structure an early outline and check the final draft against the implementation. I rewrote the article from my own implementation experience and verified the technical claims against the code.
Top comments (0)