Updating the Toolbar Badge from the Service Worker
Keep an MV3 badge count correct across worker eviction, tab switches and browser restarts: storage as the source of truth, per-tab overrides, debounced writes and restoring on startup.
Table of Contents
- Root cause: two sources of truth, one of them volatile
- Step 1. Make one function own the rendering
- Step 2. Restore it after a browser restart
- Step 3. Decide global or per-tab, and be consistent
- Step 4. Debounce the render, not the write
- Step 5. Make the colours readable in both themes
- Cross-browser variation
- Verification
- FAQ
- Related
The badge shows 3, the user acts on two items, and it still shows 3 — or it shows the right number until they restart the browser and then shows nothing at all. Badge state is held by the browser rather than by your worker, which solves the eviction problem and creates two others: the browser drops it on restart, and a per-tab value silently shadows the global one. This page is part of Notifications, Badges & the Action API and covers a badge that is always a faithful projection of your stored state.
Root cause: two sources of truth, one of them volatile
chrome.action.setBadgeText writes to browser state that survives worker eviction but not a browser restart. Your actual count lives in chrome.storage. Any code path that updates one without the other creates a divergence, and the two most common are a count incremented in memory during a wake and a badge cleared by the user’s action but never written back.
Step 1. Make one function own the rendering
Every path that changes the count should call the same renderer. Scattered setBadgeText calls are how a badge ends up correct in three code paths and wrong in the fourth.
1// badge.js
2const WARN_AT = 10;
3
4export async function renderBadge() {
5 const { pending = [] } = await chrome.storage.local.get("pending");
6 const count = pending.length;
7
8 // An empty string hides the badge entirely; "0" would show a zero, which reads as broken.
9 await chrome.action.setBadgeText({ text: count === 0 ? "" : count > 99 ? "99+" : String(count) });
10 await chrome.action.setBadgeBackgroundColor({ color: count >= WARN_AT ? "#b45309" : "#2563eb" });
11 await chrome.action.setTitle({
12 title: count === 0 ? "Review Queue" : `Review Queue — ${count} waiting`,
13 });
14}
Execution context: Extension service worker, imported wherever the count changes. Capping at 99+ matters because badge text is truncated to roughly four characters and the truncation point differs between engines — deciding the string yourself keeps it legible everywhere. Setting the title alongside the badge is what makes the count available to screen readers and to anyone who cannot distinguish the badge colour, since the badge text itself is not exposed as accessible content.
Step 2. Restore it after a browser restart
Badge state is not persisted by the browser across sessions. chrome.runtime.onStartup is the event that fires once per browser session, and it is the only reliable place to rebuild.
1// background.js
2chrome.runtime.onStartup.addListener(() => { void renderBadge(); });
3chrome.runtime.onInstalled.addListener(() => { void renderBadge(); });
4
5// Any other surface writing the count re-renders through the same path.
6chrome.storage.onChanged.addListener((changes, area) => {
7 if (area === "local" && changes.pending) void renderBadge();
8});
Execution context: Extension service worker, all three listeners registered at the top level. The onChanged listener is what keeps the badge correct when the popup or options page mutates the queue directly — without it, an action taken in the popup updates storage and leaves the badge stale until the next background event. This is the storage listener pattern applied to a display concern.
Step 3. Decide global or per-tab, and be consistent
A per-tab badge overrides the global one for that tab only, which is powerful and confusing in equal measure. Mixing the two produces a badge that appears to change as the user switches tabs, for reasons that are invisible in the code.
1// badge.js — per-tab counts, with the global badge showing the total
2export async function renderTabBadge(tabId) {
3 const key = `tab:${tabId}`;
4 const perTab = (await chrome.storage.session.get(key))[key];
5 const count = perTab?.matches ?? 0;
6
7 // null clears the override and lets the global value show through again.
8 await chrome.action.setBadgeText({ tabId, text: count === 0 ? null : String(count) });
9}
10
11// Overrides die with the tab, but clearing session state keeps memory tidy.
12chrome.tabs.onRemoved.addListener((tabId) => {
13 void chrome.storage.session.remove(`tab:${tabId}`);
14});
Execution context: Extension service worker. The distinction between "" and null is the crux: an empty string hides the badge for that tab, while null removes the override so the global value shows. Choosing null here means a tab with no matches falls back to the overall count rather than looking like the extension is inactive. Per-tab state belongs in storage.session, since it is meaningless after a restart.
Step 4. Debounce the render, not the write
A background job that adds fifty items should write once and render once. Debouncing the render alone still costs fifty storage writes; debouncing both, with the write batched, costs one of each.
1// background.js
2let queued = [];
3let flushTimer = null;
4
5export function enqueue(item) {
6 queued.push(item);
7 clearTimeout(flushTimer);
8 flushTimer = setTimeout(() => { void flush(); }, 200);
9}
10
11async function flush() {
12 const batch = queued;
13 queued = [];
14 if (batch.length === 0) return;
15
16 const { pending = [] } = await chrome.storage.local.get("pending");
17 await chrome.storage.local.set({ pending: [...pending, ...batch] });
18 await renderBadge(); // one render for the whole batch
19}
Execution context: Extension service worker. The 200 ms window is safe against eviction because the worker cannot be evicted while a handler chain is unsettled, and the batch is small enough that losing it in a crash costs one poll cycle rather than user data. Batching the storage write matters independently of the badge: it is one operation against the write-rate quota instead of fifty, which is the same reasoning as in handling storage quota exceeded errors.
Step 5. Make the colours readable in both themes
Badge text colour is chosen by the browser on some platforms and controllable on others, so the background you pick has to work against both a light and a dark glyph. That rules out mid-tone backgrounds, which look fine in one theme and illegible in the other.
1// badge.js — pick from a vetted set rather than an arbitrary colour
2const BADGE_COLOURS = { normal: "#2563eb", warn: "#b45309", error: "#dc2626", muted: "#475569" };
3
4export async function setBadgeState(state, text) {
5 await chrome.action.setBadgeBackgroundColor({ color: BADGE_COLOURS[state] ?? BADGE_COLOURS.normal });
6 await chrome.action.setBadgeText({ text });
7 // Where supported, pinning the text colour removes the platform's own guess from the equation.
8 if (chrome.action.setBadgeTextColor) {
9 await chrome.action.setBadgeTextColor({ color: "#ffffff" });
10 }
11}
Execution context: Extension service worker. Feature-detecting setBadgeTextColor matters because Safari does not offer it, so the background must be dark enough for white text there regardless. Keeping the palette to four vetted values also means a future state can be added without re-checking contrast from scratch.
Cross-browser variation
- Chrome 120+: badge text is truncated after roughly four characters;
setBadgeTextColoris available for contrast tuning. - Firefox 121+: the same
actionAPI with a slightly narrower badge;setBadgeTextColoris supported. Text is truncated sooner, so the99+cap earns its place. - Safari 17+: supports badge text and background colour; text colour control is limited, so choose background colours that work with the default text.
- All engines: badge state is per-session and per-profile. None of them persist it across a restart, and none expose the badge to assistive technology — the title is what carries that information.
Verification
- Set the badge, stop the worker from the extensions page, and confirm the badge is unchanged. Trigger an event and confirm it is still correct.
- Quit and reopen the browser. The badge should be rebuilt within a moment of the first worker wake, not left blank.
- Set a per-tab override, switch tabs, and confirm the other tabs show the global value rather than the override.
- Enqueue fifty items in a loop and count the
storage.setcalls in the worker console. There should be one.
FAQ
Why is my badge blank after restarting the browser?
Because the browser does not persist badge state across sessions. Rebuild it from chrome.runtime.onStartup, which is the only event that fires at that transition.
Should the count live in storage.local or storage.sync?
Local, in almost every case. A badge count is a device-local view of work in progress, and syncing it means one device’s activity changes another device’s badge — rarely what users expect, and it spends sync quota on volatile data.
Does setBadgeText need a permission?
No. The action is always yours to write to, so badges are the cheapest possible signal — no permission, no prompt, no review question.
Can screen reader users read the badge?
Not the badge itself. Set the action title alongside it, as in step 1; the title is what assistive technology announces when the toolbar button receives focus.
Related
- Rich notifications with buttons and images — when a badge is not enough.
- Notifications, Badges & the Action API — the guide this page belongs to.
- chrome.storage.session vs local — where per-tab counts belong.
- Managing extension state across reloads — the same discipline for popup state.