Restoring Window State After Restart
Reopen an MV3 extension's window layout after a browser restart: what the browser restores for you, capturing bounds and tab sets, and why saved window ids are always stale.
Table of Contents
- Root cause: ids are session-scoped and get recycled
- Step 1. Capture the arrangement as data
- Step 2. Persist it under a stable key, not a window id
- Step 3. Rebuild on startup, not on install
- Step 4. Ask before reopening anything large
- Step 5. Handle a display layout that has changed
- Cross-browser variation
- Verification
- FAQ
- Related
An extension that arranges windows — a research workspace, a comparison view, a detached panel — finds all of it gone after a browser restart, and its stored window ids pointing at nothing. Window and tab ids are per-session integers: they are reused freely across sessions, so a saved id is not merely stale but actively dangerous. This page is part of Tabs API & Window Management and covers capturing an arrangement as durable data and rebuilding it on the next start.
Root cause: ids are session-scoped and get recycled
windowId and tabId are allocated by the browser for the current session. After a restart the same integers are handed out again to entirely different windows and tabs, so code that reads a stored id and calls chrome.windows.update on it will silently reposition something the user opened themselves. Nothing throws, because the id is valid — it just is not yours.
Step 1. Capture the arrangement as data
Record what the window contains and where it is, not which integer it currently answers to. Capture on change rather than on close, because there is no reliable “browser is quitting” event.
1// background.js
2export async function captureWorkspace(windowId) {
3 const window = await chrome.windows.get(windowId, { populate: true });
4 if (window.type !== "normal") return null;
5
6 return {
7 savedAt: Date.now(),
8 state: window.state, // normal, minimized, maximized, fullscreen
9 bounds: { left: window.left, top: window.top, width: window.width, height: window.height },
10 tabs: (window.tabs ?? [])
11 .filter((tab) => tab.url && /^https?:/.test(tab.url))
12 .map((tab) => ({ url: tab.url, pinned: tab.pinned, active: tab.active })),
13 };
14}
Execution context: Extension service worker with the tabs permission or host access. Filtering to http and https URLs is deliberate: browser pages and extension pages either cannot be reopened by URL or should not be, and including them produces a restore that fails halfway. bounds may be undefined for a maximized window on some platforms, which is why state is captured separately and takes precedence when restoring.
Step 2. Persist it under a stable key, not a window id
The key has to mean something across sessions — the workspace’s own name, not the window it happened to occupy.
1// background.js
2const KEY = (name) => `workspace:${name}`;
3
4export async function saveWorkspace(name, windowId) {
5 const snapshot = await captureWorkspace(windowId);
6 if (!snapshot) return false;
7 await chrome.storage.local.set({ [KEY(name)]: snapshot });
8 return true;
9}
10
11// Re-capture whenever the arrangement changes, debounced so a drag is one write.
12let saveTimer = null;
13function scheduleCapture(name, windowId) {
14 clearTimeout(saveTimer);
15 saveTimer = setTimeout(() => { void saveWorkspace(name, windowId); }, 1000);
16}
17
18chrome.tabs.onCreated.addListener((tab) => { if (tab.windowId) scheduleCapture("default", tab.windowId); });
19chrome.tabs.onRemoved.addListener((_id, info) => { if (!info.isWindowClosing) scheduleCapture("default", info.windowId); });
Execution context: Extension service worker, listeners registered at the top level. Skipping the capture when isWindowClosing is true matters: during a close the tabs disappear one by one, and capturing mid-teardown records a half-empty workspace over a good one. The one-second debounce collapses a burst of tab operations into a single write, which keeps this well inside the storage write-rate limits.
Step 3. Rebuild on startup, not on install
chrome.runtime.onStartup is the once-per-browser-session event. Restoring from onInstalled would reopen the workspace every time the extension updates, which users experience as an extension that spawns windows unprompted.
1// background.js
2chrome.runtime.onStartup.addListener(() => { void restoreWorkspace("default"); });
3
4export async function restoreWorkspace(name) {
5 const snapshot = (await chrome.storage.local.get(KEY(name)))[KEY(name)];
6 if (!snapshot?.tabs?.length) return null;
7
8 const [first, ...rest] = snapshot.tabs;
9 const window = await chrome.windows.create({
10 url: first.url,
11 left: snapshot.bounds?.left, top: snapshot.bounds?.top,
12 width: snapshot.bounds?.width, height: snapshot.bounds?.height,
13 focused: false, // do not steal focus on a browser start
14 });
15
16 for (const tab of rest) {
17 await chrome.tabs.create({ windowId: window.id, url: tab.url, pinned: tab.pinned, active: false });
18 }
19
20 if (snapshot.state && snapshot.state !== "normal") {
21 await chrome.windows.update(window.id, { state: snapshot.state });
22 }
23 return window.id;
24}
Execution context: Extension service worker. focused: false is a courtesy that matters: a browser start already has a window the user is looking at, and stealing focus to a restored workspace is the behaviour that gets an extension uninstalled. Setting state after creation rather than in the create call is required because bounds and a non-normal state are mutually exclusive — passing both makes the bounds meaningless.
Step 4. Ask before reopening anything large
Restoring twenty tabs unprompted on a browser start is indistinguishable from a bug. Gate anything beyond a couple of tabs behind a user action, and use the badge or a notification to offer it rather than doing it.
1// background.js
2const AUTO_RESTORE_LIMIT = 3;
3
4chrome.runtime.onStartup.addListener(() => {
5 (async () => {
6 const snapshot = (await chrome.storage.local.get(KEY("default")))[KEY("default")];
7 if (!snapshot?.tabs?.length) return;
8
9 if (snapshot.tabs.length <= AUTO_RESTORE_LIMIT) {
10 await restoreWorkspace("default");
11 return;
12 }
13
14 // Larger workspaces are offered, not imposed.
15 await chrome.action.setBadgeText({ text: String(snapshot.tabs.length) });
16 await chrome.action.setTitle({ title: `Restore ${snapshot.tabs.length} tabs from your last session` });
17 })();
18});
Execution context: Extension service worker. Offering through the badge costs the user nothing and keeps the decision theirs, which is both better behaviour and a much easier thing to justify at review than an extension that opens windows on start.
Step 5. Handle a display layout that has changed
Saved bounds can place a window off-screen — an external monitor that is no longer attached, or a laptop that moved from a docked to an undocked position. Clamp against the current display before using them.
1// background.js — needs the "system.display" permission
2async function clampToVisibleArea(bounds) {
3 if (!bounds || !chrome.system?.display) return bounds;
4 const displays = await chrome.system.display.getInfo();
5
6 const fits = displays.some((d) =>
7 bounds.left >= d.workArea.left - 50 &&
8 bounds.top >= d.workArea.top - 50 &&
9 bounds.left + (bounds.width ?? 0) <= d.workArea.left + d.workArea.width + 50);
10
11 if (fits) return bounds;
12
13 const primary = displays.find((d) => d.isPrimary) ?? displays[0];
14 return {
15 left: primary.workArea.left + 40, top: primary.workArea.top + 40,
16 width: Math.min(bounds.width ?? 1200, primary.workArea.width - 80),
17 height: Math.min(bounds.height ?? 800, primary.workArea.height - 80),
18 };
19}
Execution context: Extension service worker with the system.display permission. The 50-pixel tolerance accommodates window decorations and slight coordinate differences between platforms without accepting a window that is genuinely off-screen. If you would rather not request the permission, the cheaper alternative is to omit bounds entirely on restore and let the browser place the window — users lose the exact geometry and never lose the window.
Cross-browser variation
- Chrome 120+:
windows.createaccepts bounds and astate, but not both meaningfully.system.displayis available with its own permission. - Firefox 121+: the same window API;
stateand bounds behave equivalently.browser.system.displayis not available, so clamp by omitting bounds rather than by measuring. - Safari 17+: window creation is supported with more limited control over placement, and the window manager may override bounds entirely. Treat geometry as advisory.
- All engines: window and tab ids are session-scoped and recycled. This is universal and is the reason the whole pattern exists.
Verification
- Arrange a window, quit the browser, reopen it, and confirm the workspace is restored to a comparable position with the right tabs in the right order.
- Save a workspace, then check that the stored object contains no window or tab ids at all.
- Restore with a saved position that is off-screen — set impossible bounds by hand — and confirm the window still appears on the primary display.
- Close a window normally and confirm the debounced capture did not overwrite the good snapshot with a partially torn-down one.
FAQ
Why not just store the window id?
Because ids are reused across sessions. A stored id will eventually refer to a window the user opened for something else, and your code will move or close it without any error to warn you.
Does the browser not restore windows itself?
It restores the user’s own session if they have that setting enabled, which covers ordinary browsing. It does not know that three particular tabs form your extension’s workspace, which is the thing worth persisting.
Should I restore automatically on startup?
Only for a small arrangement. Beyond a couple of tabs, offer it — an extension that opens windows unprompted at browser start is indistinguishable from malware to a cautious user.
What about tab groups in the restored window?
Recreate them after the tabs exist, using chrome.tabs.group with the new ids. The old group id is as stale as the window id and for the same reason.
Related
- Moving and grouping tabs programmatically — rebuilding the arrangement itself.
- Waiting for a tab to finish loading — acting on restored tabs safely.
- Alarms that don’t fire after browser restart — the other onStartup responsibility.
- Tabs API & Window Management — the guide this page belongs to.