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.
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
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.
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.
Cross-browser variation
- Chrome / Edge:
topSites.get()returns up to twenty entries with no options.sessions.getRecentlyClosedacceptsmaxResultsup to the browser’s cap, andsessions.onChangedfires when the list changes.getDevicesadditionally exposes tabs from other signed-in devices. - Firefox: both APIs exist.
browser.topSites.get()accepts options Chrome does not, includingincludePinnedandincludeFavicon, so a Firefox build can render richer entries. - Safari: neither API is available. Probe before calling — accessing
chrome.topSites.geton 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
- 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.
- Close a tab, re-read
getRecentlyClosed, and confirm the new entry is first. - Restore that entry and confirm the tab reopens with its history intact rather than as a fresh navigation.
- 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.
Related
- Searching and pruning browsing history — the store topSites is derived from.
- Reading and writing bookmarks safely — the fallback source on Safari.
- Justifying sensitive data permissions — why these two are the cheapest of the family to declare.
- Bookmarks, history and downloads APIs — the parent guide.