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.

Published July 25, 2026 Updated July 25, 2026 8 min read
Table of Contents

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.

What survives a restart, and what has to be rebuiltWindow and tab ids are session-scoped; URLs, bounds and ordering are durable data you can persist and replay.windowId / tabIdintegers reused next sessionnever persist theseGroup idsrecycled after the group diesnever persist theseURLs and titlesthe actual contentsafe to persistBounds and stateleft, top, width, heightsafe to persist
Persist the description of the arrangement, never the handles to it.

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.

Capture and restore, and where the session boundary sitsThe arrangement is captured to durable storage during the session and replayed into brand new windows and tabs on the next start.Live windowwindowId 41Captureurls, bounds, statestorage.localdurablebrowser restart — every id is discardedonStartuponce per sessionwindows.createnew windowId 7tabs.create ×Nnew tab ids
Nothing crosses the boundary except data — every id on the right is newly allocated.

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.

Window placement support by engineBounds on create, window state, display enumeration and focus control compared across the three browsers.CapabilityChrome 120+Firefox 121+Safari 17+Bounds on windows.createHonouredHonouredAdvisorystate: maximizedSupportedSupportedSupportedBounds and state togetherMutually exclusiveMutually exclusiveMutually exclusivesystem.display.getInfoWith permissionNot availableNot availablefocused: falseHonouredHonouredPartial
Only the first two columns are dependable — treat exact geometry as a request the window manager may decline.
 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.create accepts bounds and a state, but not both meaningfully. system.display is available with its own permission.
  • Firefox 121+: the same window API; state and bounds behave equivalently. browser.system.display is 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

  1. 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.
  2. Save a workspace, then check that the stored object contains no window or tab ids at all.
  3. Restore with a saved position that is off-screen — set impossible bounds by hand — and confirm the window still appears on the primary display.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources