Using the topSites and sessions APIs

Read the browser's most-visited list and recently closed tabs from an MV3 extension — what each API returns, the permissions they cost, and sensible fallbacks where they are missing.

Published September 18, 2026 Updated September 18, 2026 8 min read
Table of Contents

chrome.topSites and chrome.sessions are the two small members of the user-data family: one returns a short list of the sites the browser considers most visited, the other the tabs and windows the user recently closed. Both are read-only, both are cheap, and both are Chrome-and-Firefox only — which makes graceful absence part of the design rather than an afterthought. This guide is part of bookmarks, history and downloads APIs.

What each one actually gives you

topSites and sessions at a glanceThe two APIs compared on what they return, size of the result, whether they emit events, the permission each needs and cross-browser availability.Propertychrome.topSiteschrome.sessionsReturnsURL + title, rankedClosed tabs and windowsTypical sizeUp to 20Up to 25 entriesEventsNoneonChangedWritesNonerestore() onlyPermissiontopSitessessionsFirefoxSupportedSupportedSafariNot availableNot available
Neither has events — both are polled, which is why caching the result matters more than it looks.

Step-by-step

1. Read the most-visited list

1async function topSites() {
2  if (!chrome.topSites) return [];               // Safari, or permission not granted
3  const sites = await chrome.topSites.get();
4  return sites.map(({ url, title }) => ({ url, title: title || new URL(url).hostname }));
5}

Execution context: the service worker or an extension page. The list is derived from history and is not configurable — you cannot add to it, remove from it, or ask for more than the browser returns. Titles are sometimes empty, which is why the hostname fallback is there.

2. Cache it; do not poll it

There is no change event, and the underlying ranking moves slowly. Reading it on every popup open is wasted work.

 1const TTL = 6 * 3600e3;
 2
 3async function cachedTopSites() {
 4  const { topSitesCache } = await chrome.storage.session.get("topSitesCache");
 5  if (topSitesCache && Date.now() - topSitesCache.at < TTL) return topSitesCache.sites;
 6
 7  const sites = await topSites();
 8  await chrome.storage.session.set({ topSitesCache: { at: Date.now(), sites } });
 9  return sites;
10}

Execution context: the service worker. chrome.storage.session is the right home: the cache should not survive a browser restart, because the ranking may have shifted meaningfully by then.

3. List recently closed tabs

1async function recentlyClosed(limit = 10) {
2  if (!chrome.sessions) return [];
3  const entries = await chrome.sessions.getRecentlyClosed({ maxResults: limit });
4  return entries.map((e) => e.tab
5    ? { kind: "tab", id: e.tab.sessionId, title: e.tab.title, url: e.tab.url }
6    : { kind: "window", id: e.window.sessionId, count: e.window.tabs?.length ?? 0 });
7}

Execution context: the service worker or an extension page. Each entry is either a tab or a window, never both — a closed window arrives as one entry containing its tabs, not as one entry per tab.

4. Restore an entry

1async function restore(sessionId) {
2  try {
3    await chrome.sessions.restore(sessionId);
4  } catch {
5    // The session expired out of the list between render and click — refresh the UI.
6    await refreshList();
7  }
8}

Execution context: the service worker. Session ids are short-lived: the list is capped, so an old entry falls off as new tabs are closed and its id stops resolving. Treat a rejection as a stale-UI signal rather than an error.

5. Degrade gracefully where the APIs are missing

1async function startPanelData() {
2  const [sites, closed] = await Promise.all([cachedTopSites(), recentlyClosed()]);
3  return {
4    sites: sites.length ? sites : await bookmarkBarFallback(),   // Safari path
5    closed,                                                       // simply empty on Safari
6    showClosedSection: closed.length > 0,
7  };
8}

Execution context: the service worker. Falling back to the bookmarks bar gives Safari users a comparable panel rather than an empty one; hiding the closed-tabs section entirely is better than showing an empty heading. The capability check belongs in the shared table described in building a capability matrix for your extension.

A start panel assembled from three sourcesThe panel reads cached top sites, recently closed entries and a bookmarks fallback, composing whichever of the three the engine supports.topSites.get()cached 6 hsessions.getRecentlyClosedlive, cappedbookmarks barfallback onlycompose whatever resolvedFrequent sectiontop sites or bookmarksReopen sectionhidden when emptyRenderno spinner, no gaps
Each source is optional — the panel is defined by what it does when one is missing.

Why topSites is often the right answer, and when it is not

The most common reason an extension declares the history permission is to compute a “frequently visited” list. In nearly every one of those cases topSites would have done the job for a fraction of the review cost — it is a single permission with a mild warning, it returns a precomputed ranking, and it requires no aggregation code, no windowing and no cursor.

The trade is control. topSites gives you the browser’s ranking over the browser’s window, with no parameters. You cannot ask for the last seven days, exclude a domain, or weight by dwell time. If your product’s value is a different ranking than the browser’s, you need history. If its value is a nicer surface over roughly the same ranking, you do not.

A useful middle path is to start with topSites and let the user opt into more:

1async function frequentSites() {
2  const caps = await chrome.permissions.contains({ permissions: ["history"] });
3  if (!caps) return cachedTopSites();          // good enough, no scary prompt
4  return rankedFromHistory({ days: 30 });      // the user asked for the better version
5}

Execution context: the service worker. chrome.permissions.contains is a cheap synchronous-feeling check that never prompts, so it is safe on a hot path such as rendering a panel.

The sessions API has a similar character. getRecentlyClosed covers “reopen what I just closed” completely, and nothing else — it is capped, it expires, and it carries no search. Extensions that want a searchable history of closed tabs invariably end up maintaining their own list from chrome.tabs.onRemoved, which is more code but needs no permission beyond tabs and is not capped.

1chrome.tabs.onRemoved.addListener(async (tabId, info) => {
2  const { closedLog = [] } = await chrome.storage.session.get("closedLog");
3  const cached = tabCache.get(tabId);          // populated on onUpdated
4  if (cached?.url) closedLog.unshift({ ...cached, at: Date.now(), windowClosing: info.isWindowClosing });
5  await chrome.storage.session.set({ closedLog: closedLog.slice(0, 200) });
6});

Execution context: the service worker, with tabCache a module-level Map populated from onUpdated. The map is lost on eviction, which is why the URL is written into the log at removal time rather than looked up later — by then the tab is gone.

Which surface answers your questionA decision tree choosing between topSites, the sessions API, a self-maintained closed-tab log and the full history permission.What is the feature actually showing?Sites they use a lottopSitesone mild permissionCache for hoursno change eventsReopen what I closedsessionscapped and expiringHandle stale idsrestore can rejectSearchable closed tabsYour own logtabs.onRemovedKeep it in session storageids expire anywayA custom rankinghistorythe expensive optionAsk optionallyat the moment of use
Two of the four branches need no sensitive permission at all — check them before reaching for history.

Cross-browser variation

  • Chrome / Edge: topSites.get() returns up to twenty entries with no options. sessions.getRecentlyClosed accepts maxResults up to the browser’s cap, and sessions.onChanged fires when the list changes. getDevices additionally exposes tabs from other signed-in devices.
  • Firefox: both APIs exist. browser.topSites.get() accepts options Chrome does not, including includePinned and includeFavicon, so a Firefox build can render richer entries.
  • Safari: neither API is available. Probe before calling — accessing chrome.topSites.get on Safari throws rather than returning undefined in some versions, which is why step 1 checks the namespace, not the method.
  • All three: neither API works in a content script. Both need a round trip through the worker, as described in message passing architecture.

Verification

  1. Read both surfaces from the service worker console:
1({ sites: (await chrome.topSites.get()).length,
2   closed: (await chrome.sessions.getRecentlyClosed({ maxResults: 25 })).length });
3// { sites: 12, closed: 7 }

Execution context: the service worker console. Zero top sites on a fresh profile is normal — the ranking needs browsing history to derive from.

  1. Close a tab, re-read getRecentlyClosed, and confirm the new entry is first.
  2. Restore that entry and confirm the tab reopens with its history intact rather than as a fresh navigation.
  3. Load the build in Safari and confirm the panel renders the fallback without console errors.

FAQ

Can I remove an entry from the top sites list?

No. The list is derived and read-only. If a user wants a site gone, the only lever is deleting its history, which is covered in searching and pruning browsing history.

Does sessions.restore need the tab to have been closed in this window?

No — it restores by session id regardless of which window the tab came from, and a restored window reopens with all its tabs.

Is topSites cheaper than computing my own from history?

Considerably. It is a precomputed ranking, and it does not require the far more sensitive history permission. Prefer it whenever a rough “most visited” list is all you need.

Other Core APIs & Cross-Browser Data Management Resources