Back to articles

chrome.action in browser extensions: badges, icons, and toolbar state that survives the service worker

Build a chrome extension badge with chrome.action in MV3: per-tab badge text, a code-drawn icon, and state that survives worker teardown. Hands-on tutorial.

Maxim Kosterin
20 min read
A thin toolbar line with empty icon circles and one orange watercolor dot carrying a small blank badge, inside a faint open brush circle
A thin toolbar line with empty icon circles and one orange watercolor dot carrying a small blank badge, inside a faint open brush circle

In 2020, Chrome moved extension icons behind a new puzzle-piece menu: unless the user deliberately pinned an extension, its icon stayed out of sight (the team announced the experiment in March 2020). Six years later, that may be reversing. As of August 2026, the Chrome Extensions team says it is experimenting — in Canary, with no ship commitment — with pinning an extension's action icon to the toolbar by default, starting in Chrome 153 (official What's New page).

If that lands, your toolbar icon becomes the one piece of your extension everyone sees all day, and the badge, tooltip and icon state become your front door. chrome.action takes five minutes to learn and a few afternoons to understand, because three things trip nearly everyone: per-tab versus global state, the popup that silently eats your click handler, and the service worker that dies mid-session with your counter inside it.

Today I'm building all three lessons into one small extension.

What we're building

Tab Pulse, a four-file extension that asks for exactly one permission:

  • A toolbar button you click — each click counts a "tap" for that tab.
  • The count wears itself as a badge: per-tab, colored, capped at 99+, and put back after the page navigates.
  • The icon itself is drawn in code with OffscreenCanvas — an orange dot when the tab has taps, a gray ring when idle. No image files to ship.
  • The count lives in chrome.storage.session, so it survives the service-worker teardown that silently resets in-memory variables to zero.
  • In step 3, a popup arrives and steals onClicked — then hands it back at runtime, no reload required.

Difficulty: beginner — one manifest, three small files, no build step, no icons. Time: ~20 minutes.

How chrome.action actually works

The action manifest key is the toolbar button: an icon, a tooltip (default_title), an optional popup (default_popup), and a badge overlay (text, background color, and since Chrome 110 the text color too). It needs no permission to exist. Clicking it does exactly one of two things: if a popup is set, the popup opens; if not, chrome.action.onClicked fires in your service worker. Never both — remember that, it's step 3.

Every setter — setBadgeText, setBadgeBackgroundColor, setIcon, setTitle, setPopup — takes an optional tabId, and this is the part that surprises people. Without tabId you set the global state; with it you set a per-tab override that wins over the global value. The API reference says the override resets when the tab closes. It leaves out the other reset: Chrome also clears every per-tab value (badge, colors, icon, tooltip, popup) whenever the tab loads a new document, reloads included. That happens in ExtensionActionRunner::DidFinishNavigation in Chromium's source, and I watched it happen while testing this tutorial. Firefox documents the same reset. One API, two layers of state, and a leak in your badge if you mix them up.

The other half of the mental model is lifetime. Chrome holds your badge pixels across service-worker restarts — but it does not hold your bookkeeping. As Matt Frisbie explores in Building Browser Extensions (Apress, 2025), the extension's UI surfaces are transient by design: a popup is destroyed the moment it loses focus, and under MV3 the worker itself is killed when idle. State has to live somewhere that outlives both — which, for a per-tab count, means chrome.storage.session: memory-backed, shared with the worker's next incarnation, and cleared on browser restart. That last part is a feature here, not a bug: tab IDs are meaningless after a restart anyway, so the storage lifetime matches the data's real lifetime exactly.

A click flows from the toolbar icon into the service worker's onClicked handler, which reads and writes the per-tab count in chrome.storage.session, then repaints the badge, color, tooltip, and icon for that one tab only The whole loop: click → worker → session storage → per-tab repaint. Chrome keeps the badge across worker restarts; only storage keeps the count.

tab-pulse/
├── manifest.json
├── background.js
├── popup.html    (added in step 3)
└── popup.js      (added in step 3)

Rather clone than type? The finished extension lives in our public samples repo — load it, then read on with this tutorial as the guided tour.

Step 1: the manifest — no icons, no popup, one permission

manifest.json

{
	"name": "Tab Pulse",
	"description": "Counts taps per tab and wears the count as a badge — a tour of the chrome.action API.",
	"version": "1.0",
	"manifest_version": 3,
	"background": {
		"service_worker": "background.js"
	},
	"action": {
		"default_title": "Tab Pulse — click to count"
	},
	"permissions": ["storage"]
}

Three deliberate absences. No default_icon: step 2 draws the icon in code, which is both a legit production technique and a way to keep this tutorial file-only — Chrome shows its default icon until the first setIcon call lands. No default_popup: with a popup set, onClicked never fires, and clicks are the whole input here. And no tabs permission: onClicked hands you a tabs.Tab whose id is always present even without it (only url/title get redacted), and a tab ID is all a per-tab badge needs. The tabs.onUpdated and tabs.onRemoved events used in step 2 fire without it too; they just leave the URL out. The moment your badge depends on what the page is — counting items on a site, flagging the current URL — you'll need activeTab or tabs, and the polite way to ask is at runtime, which is exactly what my optional-permissions tutorial covers. storage is the one real permission, and it buys chrome.storage.session.

Scaffolding a real project instead of following along? My manifest generator builds an MV3 manifest from the kind of extension you pick and fills in the permissions each piece needs.

Step 2: a badge that survives the service worker

Here's the bug this whole design exists to avoid. You keep let counts = {} at the top of the worker, increment it on click, paint the badge. It works — for about 30 seconds of idleness. Then Chrome kills the worker, the next click respawns it with a fresh counts = {}, and a badge that read 5 suddenly reads 1. The pixels survived; the bookkeeping didn't.

The fix is to never own the count in worker memory: read it from chrome.storage.session, increment, write it back, paint. Listeners register at the top level so a freshly spawned worker is listening on its very first turn.

Two more things only showed up once I ran it. First, reload the page and the badge disappears, because Chrome drops per-tab state on every navigation, so the worker repaints from storage when the new page finishes loading. Second, two quick clicks can both read the old count before either write lands. In my test two simultaneous taps stored 1, not 2. A small promise chain puts every read-modify-write in line.

background.js

// Tab Pulse — the service worker.
//
// Two MV3 rules keep this file honest:
//   1. Register listeners top-level and synchronously. Chrome kills this
//      worker when it goes idle, and a freshly spawned worker only receives
//      events whose listeners were attached on the script's first turn.
//   2. Never trust a module-level variable to survive. The tap count lives in
//      chrome.storage.session, so the next tap still increments the real
//      number after the worker has been torn down and respawned.
 
const ACTIVE_COLOR = '#FB5B1A'; // badge background + "tapped" icon
const IDLE_COLOR = '#8A857E';   // hollow ring when a tab has no taps
 
// Clicking the toolbar icon is the whole input: bump this tab's count.
// This fires only because the manifest sets no default_popup.
chrome.action.onClicked.addListener((tab) => {
	if (typeof tab.id === 'number') {
		serial(() => bump(tab.id));
	}
});
 
// Give the action its idle look when the extension loads or the browser
// starts. No tabId means this sets the GLOBAL default, so every tab without
// an override of its own, including tabs opened later, shows the ring.
chrome.runtime.onInstalled.addListener(paintDefault);
chrome.runtime.onStartup.addListener(paintDefault);
 
// Chrome wipes a tab's badge, icon and title every time the tab loads a new
// document, reloads included. The count is still in storage, so put it back
// once the new page has finished loading.
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
	if (changeInfo.status === 'complete') {
		serial(() => repaint(tabId));
	}
});
 
// Per-tab action state dies with the tab; drop its count to match.
chrome.tabs.onRemoved.addListener((tabId) => {
	serial(() => forget(tabId));
});
 
// Two quick clicks can both read the old count before either write lands,
// and one tap is lost. Chaining every read-modify-write prevents that. This
// is the one module-level variable that is safe here: it only orders work
// that is still in flight, and holds nothing worth keeping.
let queue = Promise.resolve();
function serial(task) {
	queue = queue.then(task, task);
	return queue;
}
 
async function bump(tabId) {
	// Read-modify-write against session storage, never a local variable.
	const { counts = {} } = await chrome.storage.session.get('counts');
	counts[tabId] = (counts[tabId] || 0) + 1;
	await chrome.storage.session.set({ counts });
	await paint(tabId, counts[tabId]);
}
 
// One function paints every piece of the action for ONE tab: badge text,
// badge color, tooltip, icon. Passing tabId is what keeps the state from
// leaking across tabs — drop it and every setter becomes global.
async function paint(tabId, count) {
	const text = count > 99 ? '99+' : count > 0 ? String(count) : '';
	await chrome.action.setBadgeText({ text, tabId });
	await chrome.action.setBadgeBackgroundColor({ color: ACTIVE_COLOR, tabId });
	await chrome.action.setTitle({
		title: count > 0
			? `Tab Pulse — ${count} tap${count === 1 ? '' : 's'} on this tab`
			: 'Tab Pulse — click to count',
		tabId
	});
	await setDotIcon(tabId, count > 0);
}
 
// The icon is drawn in code — no PNG files to ship. OffscreenCanvas works in
// the service worker, and setIcon accepts raw ImageData keyed by size.
async function setDotIcon(tabId, active) {
	try {
		const imageData = {};
		for (const size of [16, 32]) {
			const canvas = new OffscreenCanvas(size, size);
			const ctx = canvas.getContext('2d');
			const pad = Math.max(2, Math.round(size / 12));
			ctx.beginPath();
			ctx.arc(size / 2, size / 2, size / 2 - pad, 0, Math.PI * 2);
			if (active) {
				ctx.fillStyle = ACTIVE_COLOR;
				ctx.fill();
			} else {
				ctx.strokeStyle = IDLE_COLOR;
				ctx.lineWidth = pad;
				ctx.stroke();
			}
			imageData[size] = ctx.getImageData(0, 0, size, size);
		}
		await chrome.action.setIcon({ imageData, tabId });
	} catch (err) {
		// The icon is cosmetic — never let it take the counter down with it.
		console.warn('setIcon failed', err);
	}
}
 
async function paintDefault() {
	await setDotIcon(undefined, false);
}
 
async function repaint(tabId) {
	const { counts = {} } = await chrome.storage.session.get('counts');
	if (counts[tabId]) {
		await paint(tabId, counts[tabId]);
	}
}
 
async function forget(tabId) {
	const { counts = {} } = await chrome.storage.session.get('counts');
	if (tabId in counts) {
		delete counts[tabId];
		await chrome.storage.session.set({ counts });
	}
}

Tab Pulse toolbar icon with an orange count badge reading 4 The badge is per-tab state, and it survives service-worker restarts because the count never lived in the worker

Worth lingering on four details:

  • tabId is the whole per-tab trick — watch it leak. Edit paint() to drop tabId from the setBadgeText call, reload, open two tabs, tap one a few times: both tabs now show that number, because the setting went global. Restore tabId and they're independent again. Per-tab overrides win over global values and reset when the tab closes or navigates; global values persist until you change them.
  • Four characters, roughly. The badge renders about four characters before Chrome clips; 99+ is the community convention for "many", and the API reference recommends keeping it short. Clear a badge with setBadgeText({ text: '' }), not null games.
  • Colors take two shapes. setBadgeBackgroundColor accepts a CSS string ('#FB5B1A', 'orange', '#F008') or an RGBA array ([251, 91, 26, 255]). Chrome picks black or white badge text automatically for contrast; setBadgeTextColor (Chrome 110+) overrides that. Firefox has had a text-color setter for longer — one of those small cross-browser asymmetries.
  • setIcon has three inputs and one trap. A path map ({16: 'icon16.png', ...} — real PNG files, SVG is not supported), raw ImageData as above, or nothing to reset to the manifest icon. The trap: paths are per-size, and if you skip sizes Chrome scales what you gave it, which is how blurry icons are born. Drawing at 16 and 32 like setDotIcon() does keeps it crisp on HiDPI screens.

One more icon-state tool lives in the same API: chrome.action.disable(tabId) grays the icon but keeps it on the toolbar. A click then neither fires onClicked nor opens the popup; Chrome shows the extension's context menu instead. It's the MV3 descendant of the old pageAction show/hide dance, and the canonical use is the declarativeContent pattern from the API reference: rules that disable your action everywhere and re-enable it on sites you can actually help, re-evaluated on every navigation so nobody gets stranded. (Stranded is the failure mode to fear: a popup button that disables its own action, then closes, leaves the user a grayed icon with no way back. If you build "pause on this site", build the un-pause first.)

The same Chrome window with each of three tabs active in turn: the Tab Pulse badge reads 4, then 1, then shows only the gray idle ring on a fresh tab One window, three tabs: 4 taps, 1 tap, and a fresh tab that falls back to the global idle ring. Drop tabId from the setters and one number leaks across all of them

Step 3: popup or click — you don't get both

The single most common chrome.action bug: an extension ships with a click handler, someone adds default_popup to "show a little panel", and the click handler mysteriously stops firing. It's not mysterious — a popup is the click handler now. Google's Page Redder sample is the pure-click extreme: "action": {} in the manifest, one onClicked listener that reddens the page. No popup, no badge, ten lines.

Let's cross to the other side on purpose. Add a popup to the manifest — merge this into your action key:

"action": {
	"default_title": "Tab Pulse — click to count",
	"default_popup": "popup.html"
}

Now the two popup files. Keep the golden rule in mind: a popup dies on blur, so it reads state and issues commands — it never owns anything.

popup.html

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
	<title>Tab Pulse</title>
	<style>
		body {
			width: 260px;
			margin: 0;
			padding: 14px;
			font-family: system-ui, sans-serif;
			color: #1f1b16;
			background: #ffffff;
		}
		h1 { margin: 0 0 8px; font-size: 15px; }
		p { margin: 0 0 12px; font-size: 13px; line-height: 1.5; }
		button {
			width: 100%;
			padding: 8px 10px;
			border: 1px solid #fb5b1a;
			border-radius: 8px;
			background: #ffffff;
			color: #fb5b1a;
			font-size: 13px;
			font-weight: 600;
			cursor: pointer;
		}
		button:hover { background: #fb5b1a; color: #ffffff; }
		code { font-size: 12px; }
	</style>
</head>
<body>
	<h1>Tab Pulse</h1>
	<p id="count">…</p>
	<button id="click-mode">Back to click mode</button>
	<p><code>setPopup({ popup: '' })</code> hands the button back to <code>onClicked</code> — no reload.</p>
	<script src="popup.js"></script>
</body>
</html>

popup.js

// With default_popup set, chrome.action.onClicked no longer fires — that IS
// the lesson of this step. So the popup is a control surface: it shows the
// count and hands the toolbar button back to click mode.
 
const countEl = document.getElementById('count');
const clickModeBtn = document.getElementById('click-mode');
 
async function activeTabId() {
	const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
	return typeof tab?.id === 'number' ? tab.id : null;
}
 
async function init() {
	const tabId = await activeTabId();
	if (tabId === null) {
		countEl.textContent = 'Open this on a normal tab.';
		return;
	}
	const { counts = {} } = await chrome.storage.session.get('counts');
	const n = counts[tabId] || 0;
	countEl.textContent = n === 0
		? 'No taps on this tab yet.'
		: `${n} tap${n === 1 ? '' : 's'} on this tab.`;
}
 
clickModeBtn.addEventListener('click', async () => {
	// Clearing the popup re-enables chrome.action.onClicked everywhere.
	// Runtime setPopup overrides the manifest until the extension reloads.
	await chrome.action.setPopup({ popup: '' });
	window.close();
});
 
init();

Tab Pulse popup showing the tap count for the current tab and a back-to-click-mode button With a popup set, clicking the icon opens this instead of counting. onClicked is gone until setPopup clears it

Reload the extension and click: no counting anymore, just the panel — onClicked is dead while a popup is set. Hit Back to click mode and click again: counting resumes without reloading the extension, because setPopup({ popup: '' }) is a runtime override of the manifest value. That round trip is the entire relationship in miniature: the manifest declares the default, the runtime setters override it per tab or globally, and a reload resets everything back to the manifest.

Load it and try it

  1. Create a tab-pulse/ folder with the four files from steps 1–3 (skip the popup files for a pure click-mode build).
  2. Open chrome://extensions, flip Developer mode on (top right), click Load unpacked, pick the folder.
  3. Pin Tab Pulse via the puzzle-piece menu so the badge is visible.
  4. On any normal page, click the icon. Badge reads 1, the drawn dot turns orange, hover shows the tooltip. Click three more times: 4.
  5. Open a second tab: gray ring, no badge, because nothing set per-tab state for it yet and it shows the global default. Tap once there: 1 on this tab, and the first tab still shows 4 when you switch back.
  6. Reload the first tab. The badge blinks away while the page loads, then 4 comes back. Comment out the tabs.onUpdated listener, reload the extension and try again: the badge stays gone, though the count is still in storage.
  7. The teardown test: leave Chrome alone for ~30 seconds, then check the extension card at chrome://extensions — the "service worker" link should read inactive. Click the icon again. The count continues (5), it does not restart at 1. That's storage.session earning its keep.
  8. Add default_popup (step 3), reload the extension, click: the popup opens instead of counting. Use its button to go back.
  9. Bonus clipping demo: on the extension card, click service worker to open its console and run chrome.action.setBadgeText({ text: '99999' }) — Chrome renders about four characters and no more.

If it doesn't work, it's nearly always one of these:

  • Clicks do nothing and there's no popup → a popup is set somewhere: default_popup in the manifest, or a runtime setPopup that survived until reload. Remove it and reload the extension — runtime overrides reset.
  • The badge resets to 1 after a pause → you're incrementing a worker variable instead of storage, and the worker died in between. That's the exact bug this design avoids; check bump().
  • The badge vanishes when the page reloads or navigates → that's Chrome clearing per-tab state. Repaint from storage in tabs.onUpdated.
  • Fast double-clicks count once → the read-modify-write isn't going through serial().
  • chrome.storage.session is undefined → the "storage" permission is missing from the manifest (session storage also needs Chrome 102+).
  • The icon never changes → open the worker console from the extension card; setIcon with a path needs real PNGs (SVG is not supported), and imageData keys must be the pixel sizes.
  • Every tab shows the same number → some setter lost its tabId and went global.

Cross-browser: same action, different worker

Firefox runs almost all of this unchanged under browser.action: badges, per-tab setters, onClicked and popups are all in MDN's action docs, and Firefox has storage.session too. It also resets per-tab badge text on navigation, and says so in its docs. The differences cluster in the background:

  • Firefox has no extension service workers. Your background is an event page (background.scripts), also torn down when idle — so the "don't keep the count in worker memory" lesson transfers verbatim, it just has a different corpse.
  • Test the drawn icon. setIcon with raw ImageData from an OffscreenCanvas works in Chrome's worker; re-verify in Firefox's event page before relying on it, or fall back to path-based PNGs, which work everywhere.
  • Firefox had setBadgeTextColor long before Chrome 110, and lets you set per-tab badge colors the same way — check MDN's compat table for the versions you target.
  • Edge is Chromium: Tab Pulse runs there byte-identical. The chrome.* namespace also works in Firefox; if you'd rather write browser.* everywhere, Chrome has shipped it as a native alias since 148 — see my notes on the namespace going GA.

Common pitfalls and best practices

  • Register onClicked top-level and synchronous. A listener added after an await misses the event that woke the worker — the same async-registration bug that haunts every MV3 API.
  • A badge is state, not a notification. It sits there until you change it, and it's invisible when the icon isn't pinned. For something that interrupts and dismisses, use chrome.notifications — which I built out in my notifications tutorial.
  • Check getUserSettings().isOnToolbar (Chrome 91+). If the user never pinned you, your beautiful badge lives behind the puzzle piece — that's the moment for a gentle onboarding nudge. If the Chrome 153 pinning experiment ships, this call starts returning true by default for a lot of people.
  • Don't store per-tab state you need to keep. Per-tab action settings die on navigation and tab close, storage.session on browser restart. That's right for ephemeral UI; for anything durable, key it by something stable in storage.local.
  • Wear honest numbers. A fake 1 that never resolves trains users to ignore your badge forever.
  • Popup = transient, worker = logic, storage = truth. Let each own what it's good at and the API stops fighting you.

Before you ship it

Tab Pulse draws its action icon at runtime, but the Chrome Web Store still requires a real icons block (128px) before you submit anywhere. For more to play with, Google's api-samples/action exercises every setter on this page (Apache-2.0).

The badge is the visible half of the action; the click is the invisible half, and it pairs with the rest of the quick-action surface: context menus for right-click, keyboard shortcuts for the _execute_action command (which itself splits on the popup/click line you just learned), and the side panel when a popup isn't big enough.

And before the store sees it, run the same checks I run: the Extenshi CLI (npx @extenshi/cli) scans your manifest and permission footprint — three scans free with a free account, one-time, then prepaid credit packs that never expire. Curious how shipped extensions wear their badges? Browse tab-counter extensions in the catalog, or check any extension to see exactly what it declares.

Explore Extension Analytics →

Sources

Further reading

📚 Building Browser Extensions, 2nd Edition by Matt Frisbie — Amazon | Apress. Chapter 7 walks the full extension-UI surface — actions, popups, options pages — including why popup documents are transient and how the action fits the rest of the browser chrome.

This article is a hands-on tutorial based on the official Chrome and Mozilla extension documentation and Google's Apache-2.0 sample code. Code is provided as-is for educational purposes; verify API behavior against the current developer.chrome.com reference before shipping. Extenshi does not independently verify all claims made by third-party authors, and references to specific products reflect cited documentation, not accusations of wrongdoing. If you believe any information is inaccurate, contact [email protected] and we will review and update.

Related Articles