Skip to content

Badging API

The Badging API lets an installed web app put a badge on its icon, in the Windows taskbar, the macOS Dock, the ChromeOS shelf or the iOS Home Screen, with two calls: navigator.setAppBadge(count) and navigator.clearAppBadge(). It's the quiet counterpart to notifications: an unread count or a "something changed" dot that doesn't interrupt the user. It works in Chromium-based browsers on Windows, macOS and ChromeOS, in Safari for Home Screen web apps on iOS and iPadOS 16.4 and later, and for web apps on Mac from Safari 17. This page covers the exact API and its algorithm, per-platform behavior (including the platforms where the call succeeds but nothing appears), permission rules, setting badges from push events, unread-count patterns and fallbacks for browsers without app badges.

Key takeaways

  • Two methods, write-only. setAppBadge(contents?) and clearAppBadge() return promises and exist on navigator in pages and in service workers. There is no getter, by design, so you track the value yourself.
  • Three states. No argument sets a "flag" (a dot in Chromium), a positive integer sets a number, and 0 clears the badge. Conversion follows Web IDL [EnforceRange]: 3.7 becomes 3, 0.4 becomes 0 and clears, and -1 or NaN rejects with a TypeError.
  • Installed apps only. Chromium badges the installed app whose scope contains the page (even from a browser tab) on Windows, macOS and ChromeOS. Safari badges Home Screen web apps and Dock web apps. Firefox doesn't implement the API.
  • A resolved promise doesn't mean a visible badge. Chrome on Android and on Linux resolves and does nothing. Chromium shows 99+ above 99. iOS only displays the badge after the user grants notification permission.
  • On iOS, badges need notification permission and a running context. The calls work while the app is in the foreground or handling a push. Put the count in the push payload and set it in the push handler.
  • Keep the count on the server. Send the authoritative unread count in every push, reset it when the user reads, and re-apply it on launch, because Chromium keeps badge values only in memory.

What an app badge is (and isn't)

A badge is a small indicator the operating system draws on or next to an app's icon. The Badging API specification (a W3C Working Draft from the Web Applications Working Group; the latest TR version is dated 27 April 2026) defines exactly one badge per installed web application, stored and managed by the operating system, with the browser acting as an intermediary. The badge has one of three values:

Value Set by What the spec says the UA should display
"nothing" clearAppBadge(), setAppBadge(0), or the UA resetting it No badge
"flag" setAppBadge() with no argument A non-specific indicator, such as a colored circle. If the platform can't show one, "the closest available representation … (e.g., the value '1'), rather than clearing the badge entirely"
A number greater than 0 setAppBadge(n) The number, formatted and localized for the user. The UA may simplify it ("a badge with a value of '100' as '99+'") or show it as a flag

A few properties of the model shape how you use it:

  • One badge per app, last write wins. "If multiple API calls within the same application set or clear a badge, the most recent one takes effect, and may continue being seen even after an application is closed." Every window and the service worker write to the same badge.
  • Write-only. The specification's privacy section: "There is no way for a site to read back the value of a badge that was previously set, to ensure that the application badge cannot be used as a storage or fingerprinting mechanism."
  • The UA can clear it. The UA "MAY (re)set an application's badge to 'nothing' at its discretion (for example, following system conventions)". A system reset, a browser restart or reinstalling the app can all clear it.
  • Not a document badge. Earlier drafts also had a per-document badge for browser tabs. The current specification only defines the app badge, and no browser exposes anything else. For badges on a tab, you still use the favicon or the title (see Fallbacks).

Badges and notifications complement each other. A notification says "this happened, look now". A badge says "there are N things waiting when you get to it". Many apps do both from the same push message.

API reference

Badging API (Web IDL)
[SecureContext]
interface mixin NavigatorBadge {
  Promise<undefined> setAppBadge(optional [EnforceRange] unsigned long long contents);
  Promise<undefined> clearAppBadge();
};

Navigator includes NavigatorBadge;
WorkerNavigator includes NavigatorBadge;
setAppBadge(contents)
Sets the badge. Omitted or undefined sets the flag; 0 sets "nothing"; any other value sets that number. Returns a promise that resolves with undefined.
clearAppBadge()
Equivalent to setAppBadge(0). Returns a promise that resolves with undefined.

Both are only exposed in secure contexts. Chromium exposes the WorkerNavigator methods only in service workers; in a dedicated or shared worker, navigator.setAppBadge is undefined. WebKit includes them in every worker type. For portable code, call them from a page or a service worker.

The "set the application badge" algorithm

The specification runs these steps for both methods:

  1. If the caller is a window and its document is not fully active (for example, it's in the back/forward cache or in a detached iframe), return a promise rejected with InvalidStateError.
  2. If the document's origin is not same origin-domain with the top-level origin, return a promise rejected with SecurityError. Cross-origin iframes can't badge the app that embeds them.
  3. In parallel: if the UA requires express permission to badge, get the current state of the "notifications" permission. If it isn't "granted", reject with NotAllowedError.
  4. Set the badge to the flag (no argument), "nothing" (0) or the number.
  5. Queue a task to resolve the promise.

Step 3 is optional for UAs. Chromium doesn't require a permission. Safari doesn't reject either; it accepts the value and decides whether to display it based on the notification permission (see Safari on iOS, iPadOS and macOS).

How arguments are converted

contents is an unsigned long long with [EnforceRange]. Web IDL converts the argument before the method runs. Because the method returns a promise, a conversion error becomes a rejected promise with a TypeError, not a synchronous exception.

Call Converted to Result
setAppBadge() / setAppBadge(undefined) argument not passed Flag
setAppBadge(7) 7 Number 7
setAppBadge("12") 12 Number 12 (strings go through ToNumber)
setAppBadge(3.7) 3 Number 3: truncated toward zero
setAppBadge(0.4) 0 Cleared
setAppBadge(null) 0 Cleared (ToNumber(null) is 0)
setAppBadge(-1) – Rejects with TypeError (out of range)
setAppBadge(NaN), setAppBadge(Infinity) – Rejects with TypeError
setAppBadge(2 ** 53) – Rejects with TypeError (above Number.MAX_SAFE_INTEGER)

Two of those surprise people in production. Averages or ratios that round down to zero clear the badge, and a null count from an API response clears it too. Validate with Number.isSafeInteger(n) && n >= 0 before calling.

Exceptions and rejections

Rejection When Who raises it
TypeError Argument fails [EnforceRange] conversion All engines (Web IDL)
InvalidStateError Document not fully active; in WebKit also a window without a frame or page Spec, Chromium, WebKit
SecurityError Called from a frame that isn't same origin with the top-level document Spec, WebKit
NotAllowedError UA requires notification permission and it isn't granted (spec); Chromium: called inside a fenced frame ("The badge API is not allowed in this context") Spec (optional), Chromium

Chromium handles cross-origin iframes differently from the specification: instead of rejecting, it binds no badge service for third-party contexts, so the call resolves and does nothing.

Platform support and behavior

The API surface is identical everywhere. What happens after the promise resolves isn't.

Platform Browser Since Where the badge appears Permission needed Notes
Windows Chrome, Edge 81 Overlay on the app's taskbar button No Only while an app window is open; 99+ above 99; flag shows •
macOS Chrome, Edge 81 App's Dock icon (via the app shim) Since Chrome 152, notification permission for display Same saturation and flag rules
ChromeOS Chrome 91 Shelf and launcher icon No Notifications can also badge the icon
Linux Chrome, Edge – Nowhere – Methods resolve; MDN: "Linux offers no universal badging API on the operating system level"
Android Chrome – Nowhere via the API – Methods exist and resolve; the launcher shows a dot for unread notifications instead
iOS / iPadOS Safari (Home Screen web apps) 16.4 Home Screen icon Notification permission, for display Foreground or during a push event
macOS Sonoma+ Safari (web apps on Mac) 17 Dock icon of the web app Not documented; request notification permission Not in Safari tabs
All Firefox – – – Not implemented

Chromium on Windows, macOS and ChromeOS

Chromium's implementation lives in chrome/browser/badging/. The source code answers several questions its documentation doesn't:

  • Which app gets the badge. From a page, Chromium looks up the installed app whose scope best matches the page's URL, among apps whose badges the OS can display. That lookup doesn't depend on the page running in the app window. A tab in the normal browser that is inside an installed app's scope can set that app's badge. From a service worker, Chromium badges every installed app whose scope is nested inside the worker's scope. If no installed app matches, the call resolves and does nothing.
  • Saturation. Values above 99 (kMaxBadgeContent) display as a localized 99+. The flag displays as •. Pass the real count anyway; the browser handles display.
  • Accessible text. On Windows, the overlay carries an accessible description: a pluralized "N unread notifications", "More than 99 unread notifications" when saturated, or a generic "Unread Notifications" for a flag.
  • Windows needs an open window. The Windows delegate applies the overlay to the taskbar buttons of the app's open windows. A pinned but closed app shows no badge.
  • Badges live in memory. The current value is kept in a per-profile in-memory map. After a browser restart, apps start without badges until your code sets them again, so re-apply the count on launch (and in the service worker when it wakes).
  • No permission rejection. Chromium never rejects the promise for a missing notification permission. On Windows and ChromeOS the badge displays without it. On macOS, since Chrome 152, an installed app's Dock badge only appears when the app has notification permission, so request it before relying on the badge there.
  • Notifications versus the API. On platforms where Chromium also badges app icons for notifications (such as ChromeOS), it stops doing that for an app that has used the Badging API in the last 14 days (kBadgingOverrideLifetime), so your explicit count isn't overridden by a notification count.

Chrome on Android

Chrome's documentation is direct: "On Android, the Badging API is not supported. Instead, Android automatically shows a badge on app icon for the installed web app when there is an unread notification." In Chromium's source, the renderer skips the call to the badge service on Android but still resolves the promise, and the methods are still exposed. So "setAppBadge" in navigator is true, await navigator.setAppBadge(5) succeeds, and nothing changes on the icon. That departs from the specification, which says "User agents that never display application badges SHOULD NOT include NavigatorBadge", and it's why feature detection can't tell you whether a badge will be visible. On Android, the practical badge is the launcher's notification dot, driven by notifications your WebAPK has showing. Leave notifications in the tray (don't close() them automatically) if you want that dot to persist.

Safari on iOS, iPadOS and macOS

WebKit shipped the API for Home Screen web apps in iOS and iPadOS 16.4 and for web apps on Mac in Safari 17 on macOS Sonoma. Apple's rules:

  • Display is tied to notification permission. WebKit: "Permission for a Home Screen web app to use the Badging API is automatically granted when a user gives permission for notifications." Before that, the calls still resolve and still change the stored count, "even before permission to display the count has been granted". Once the user allows notifications, "the icon on the Home Screen will immediately display the current badge count". Users can later switch badges off separately in Settings > Notifications > your app.
  • When you can call it. "Both setAppBadge and clearAppBadge change the count while the user has the web app open in the foreground or while the web app is handling push events in the background." iOS has no other background execution for web apps (no Background Sync, no Periodic Background Sync), so push is your only way to update the badge while the app is closed.
  • Only in web apps. MDN notes badging "is supported for web apps saved to the home screen" on iOS and "for installed web apps on macOS Sonoma and higher". Safari tabs don't badge anything.
  • Same-site check. On iOS, WebKit's push daemon only applies a badge when the calling origin is same site with the web app's page URL. A badge call from an unrelated origin loaded inside the app is ignored.
  • Resolves immediately. WebKit's window implementation resolves the promise as soon as it forwards the value, without waiting for the OS.
  • Numbers, not flags. iOS Home Screen badges are numbers. WebKit forwards the flag as "no number", and Apple doesn't document how the flag form displays. Pass an explicit count, and use 1 if you only need an indicator.
  • Declarative Web Push can carry the badge. From Safari 18.4, a declarative push message can include app_badge, and the system applies it without running JavaScript (see below).

Firefox

Firefox implements neither setAppBadge() nor clearAppBadge(), on desktop or Android, so feature detection returns false. Use the fallbacks.

Feature detection and a safe wrapper

"setAppBadge" in navigator tells you whether the method exists, not whether a badge will appear (Android and Linux prove the difference). Detection is still worth doing to avoid TypeErrors in Firefox, and every call should be wrapped because the promise can reject.

badge.js
// App badge helper for pages. Safe to call anywhere: it validates input,
// coalesces rapid updates and never throws.
const supportsAppBadge = "setAppBadge" in navigator && "clearAppBadge" in navigator;

let pending = null; // latest requested value
let scheduled = false;

export function setUnreadBadge(count) {
  // Normalize: null/undefined/negative/non-integers never reach the API.
  pending = Number.isSafeInteger(count) && count > 0 ? count : 0;
  if (scheduled) return;
  scheduled = true;
  // Coalesce bursts (for example, 20 messages arriving in one sync) into
  // one OS update. The spec asks authors to avoid rapidly changing values.
  queueMicrotask(async () => {
    scheduled = false;
    const value = pending;
    await applyAppBadge(value);
    applyFallbackBadge(value);
  });
}

async function applyAppBadge(value) {
  if (!supportsAppBadge) return false;
  try {
    if (value > 0) await navigator.setAppBadge(value);
    else await navigator.clearAppBadge();
    return true;
  } catch (error) {
    // InvalidStateError (bfcache), SecurityError (cross-origin frame),
    // NotAllowedError (fenced frame / UA policy). The badge is cosmetic.
    console.debug("App badge not applied:", error.name);
    return false;
  }
}

function applyFallbackBadge(value) {
  // Tabs (and browsers without app badges) get a title prefix instead.
  // In an installed window the app badge is enough, so skip it there.
  const installed =
    window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
  const base = document.title.replace(/^\(\d+\+?\) /, "");
  document.title = !installed && value > 0 ? `(${value > 99 ? "99+" : value}) ${base}` : base;
}

navigator.standalone is a non-standard WebKit property that's true inside an iOS or iPadOS Home Screen web app and, since Safari 17, a web app in the macOS Dock. Detecting Installed Apps covers display-mode detection across browsers.

Setting the badge from push events

For most apps, the badge should change when something happens on the server, often while the app is closed. That means a push message and the service worker's push event. On iOS this is the only background path, and Safari requires every push to show a notification (see Web Push on iOS & Safari), so set the badge and show a notification.

Send the authoritative count in the payload rather than making the worker increment a local counter. Pushes can arrive out of order, be collapsed by the push service's Topic header, expire offline or be delivered twice. An absolute value converges; an increment drifts.

push payload
{
  "web_push": 8030,
  "notification": {
    "title": "Ada Lovelace",
    "body": "Are we still on for Thursday?",
    "navigate": "https://chat.example/threads/42",
    "tag": "thread-42",
    "data": { "threadId": "42", "url": "https://chat.example/threads/42" }
  },
  "app_badge": 7
}

The payload uses the declarative push shape. Safari 18.4+ can display it and apply app_badge with no JavaScript. Every other browser hands it to your service worker, which does the same work:

sw.js
// Push handler that shows the notification and applies the server's unread count.
self.addEventListener("push", (event) => {
  event.waitUntil(handlePush(event));
});

async function handlePush(event) {
  let message = null;
  try {
    message = event.data ? event.data.json() : null;
  } catch {
    message = null;
  }

  // Safari 18.4+ mutable declarative messages come with a proposed
  // notification instead of data. Showing nothing here is safe: Safari
  // displays the proposed notification and applies app_badge itself.
  if (!message && event.notification) return;

  const n = message?.notification ?? {};
  const title = typeof n.title === "string" && n.title ? n.title : "New activity";
  const extra = typeof n.data === "object" && n.data !== null ? n.data : {};
  const data = { ...extra, url: typeof n.navigate === "string" ? n.navigate : "/" };

  // 1. Notification first: Safari penalizes pushes without one.
  await self.registration.showNotification(title, {
    body: typeof n.body === "string" ? n.body : "",
    tag: typeof n.tag === "string" ? n.tag : undefined,
    data,
  });

  // 2. Then the badge. Never let a badge failure reject the event.
  await setBadgeSafely(message?.app_badge);
}

async function setBadgeSafely(count) {
  if (!("setAppBadge" in self.navigator)) return;
  if (!Number.isSafeInteger(count) || count < 0) return;
  try {
    if (count === 0) await self.navigator.clearAppBadge();
    else await self.navigator.setAppBadge(count);
  } catch {
    // Unsupported platform behavior or policy rejection: ignore.
  }
}

// Tapping a notification usually means "I'm reading it now".
self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  event.waitUntil(
    (async () => {
      const url = new URL(event.notification.data?.url ?? "/", self.location.origin).href;
      const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
      const existing = windows.find((c) => new URL(c.url).origin === self.location.origin);
      if (existing) {
        await existing.focus();
        existing.postMessage({ type: "open-url", url });
      } else {
        await self.clients.openWindow(url);
      }
      // The page recalculates and sets the real count once it loads the
      // thread; see unread-sync.js. Don't guess here.
    })(),
  );
});

Why the page, not the click handler, sets the new count: tapping a notification doesn't necessarily mean everything is read, and only the page knows what the user actually saw. If you know that opening a thread marks exactly one conversation as read and your server returns the new total, you can set it in the click handler instead.

For Declarative Web Push details, including the history of where app_badge goes in the JSON, see Declarative Web Push. In short: current WebKit reads a top-level app_badge (integer, or a string of digits); the first 18.4 implementation read it from inside notification. When a mutable declarative message's handler calls setAppBadge(), WebKit records the value on the event instead of applying app_badge. Don't await that call before showNotification().

Unread-count patterns

The badge should match what the user sees in the app. Keeping it right needs one source of truth and a few synchronization points.

sequenceDiagram
    participant Srv as Server
    participant SW as Service worker
    participant Page as App window
    participant OS as OS badge
    Srv->>SW: push (notification + unreadCount 7)
    SW->>OS: setAppBadge(7)
    Page->>Srv: mark thread 42 read
    Srv-->>Page: unreadCount 5
    Page->>OS: setAppBadge(5)
    Page->>Page: BroadcastChannel unread = 5
    Note over Page: Other open windows update their UI

The rules that make this robust:

  1. The server owns the count. Every API response that changes read state returns the new total, and every push carries it.
  2. Set absolute values. Never count + 1 in the client.
  3. Re-apply on launch and on focus. Chromium forgets badges when the browser restarts, other devices mark items read, and pushes may have expired. Fetch the count on visibilitychange when the page becomes visible.
  4. Clear when the count is zero. setAppBadge(0) and clearAppBadge() are equivalent; use whichever reads better.
  5. Sync windows. Several windows of the same app share one badge but have separate UI state. A BroadcastChannel keeps their in-app indicators consistent.
unread-sync.js
import { setUnreadBadge } from "./badge.js";

const channel = "BroadcastChannel" in self ? new BroadcastChannel("unread") : null;
const listeners = new Set();
let current = 0;

export function onUnreadChange(fn) {
  listeners.add(fn);
  fn(current);
  return () => listeners.delete(fn);
}

function apply(count, { broadcast }) {
  if (!Number.isSafeInteger(count) || count < 0) return;
  current = count;
  setUnreadBadge(count); // OS badge (+ title fallback in tabs)
  for (const fn of listeners) fn(count); // in-app indicator
  if (broadcast) channel?.postMessage(count);
}

channel?.addEventListener("message", (event) => apply(event.data, { broadcast: false }));

// Call with the unreadCount returned by any API response that changes read state.
export function reportUnreadFromServer(count) {
  apply(count, { broadcast: true });
}

export async function refreshUnread() {
  try {
    const response = await fetch("/api/unread-count", { credentials: "same-origin" });
    if (!response.ok) return;
    const { unreadCount } = await response.json();
    apply(unreadCount, { broadcast: true });
  } catch {
    // Offline: keep the last known value rather than clearing it.
  }
}

// Keep the badge honest when the user comes back to the app.
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") refreshUnread();
});

// The service worker asks open windows to show a URL after a notification tap.
navigator.serviceWorker?.addEventListener("message", (event) => {
  if (event.data?.type === "open-url") window.location.assign(event.data.url);
});

refreshUnread(); // on every launch: Chromium doesn't persist badges across restarts

When there's no server count

Some apps can't compute an unread count cheaply on the server. A reasonable approximation is the number of notifications your app currently has on screen, which registration.getNotifications() returns:

sw.js (excerpt)
// Approximate badge = notifications still showing. Call after showing or
// closing a notification. Tag-replaced notifications count once.
async function badgeFromVisibleNotifications() {
  if (!("setAppBadge" in self.navigator)) return;
  const shown = await self.registration.getNotifications();
  try {
    if (shown.length > 0) await self.navigator.setAppBadge(shown.length);
    else await self.navigator.clearAppBadge();
  } catch {
    // ignore
  }
}

self.addEventListener("notificationclose", (event) => {
  event.waitUntil(badgeFromVisibleNotifications());
});

This drifts when the user reads items elsewhere, and notificationclose doesn't fire on every platform or for every way a notification can disappear. Treat it as a fallback, not a replacement for a real count. See Notifications API for getNotifications() and close-event behavior.

Refreshing the badge without a push (Chromium)

On Chromium, an installed app can also refresh the count in the background with Periodic Background Sync. The browser decides how often it runs, based on engagement, and it doesn't exist in Safari or Firefox. It's a good way to correct drift for users who have notifications turned off:

sw.js (excerpt)
self.addEventListener("periodicsync", (event) => {
  if (event.tag !== "refresh-unread") return;
  event.waitUntil(
    (async () => {
      const response = await fetch("/api/unread-count", { credentials: "same-origin" });
      if (!response.ok) return;
      const { unreadCount } = await response.json();
      if (!Number.isSafeInteger(unreadCount) || unreadCount < 0) return;
      if (unreadCount > 0) await self.navigator.setAppBadge(unreadCount);
      else await self.navigator.clearAppBadge();
    })(),
  );
});

Register it from the page with registration.periodicSync.register("refresh-unread", { minInterval: 12 * 60 * 60 * 1000 }) after checking the periodic-background-sync permission. The details are on the Periodic Background Sync page.

Fallbacks: favicon and title badges

When the app runs in a browser tab, or in a browser without app badges, the familiar alternatives are a count in document.title and a badge drawn onto the favicon. The title prefix is the most portable and is read by screen readers when the tab is focused. The favicon is a visual extra. Browsers differ in how quickly, and whether, they repaint a favicon that changes at runtime, so never depend on it alone.

favicon-badge.js
// Draws a count onto the site's favicon. Keep the original so it can be restored.
const link =
  document.querySelector('link[rel~="icon"][sizes="32x32"]') ||
  document.querySelector('link[rel~="icon"]');
const originalHref = link?.href ?? "/favicon.ico";
let baseImage = null;

function loadBaseImage() {
  if (baseImage) return baseImage;
  baseImage = new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = "anonymous"; // needed if the icon is on a CDN, or toDataURL() throws
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = originalHref;
  });
  return baseImage;
}

export async function setFaviconBadge(count) {
  if (!link) return;
  if (!count) {
    link.href = originalHref;
    return;
  }
  try {
    const img = await loadBaseImage();
    const size = 64; // draw at 2x; the browser scales it down
    const canvas = document.createElement("canvas");
    canvas.width = canvas.height = size;
    const ctx = canvas.getContext("2d");
    ctx.drawImage(img, 0, 0, size, size);

    const label = count > 99 ? "99+" : String(count);
    const radius = label.length > 2 ? 24 : 20;
    const cx = size - radius;
    const cy = radius;
    ctx.beginPath();
    ctx.arc(cx, cy, radius, 0, Math.PI * 2);
    ctx.fillStyle = "#d93025";
    ctx.fill();
    ctx.fillStyle = "#ffffff";
    ctx.font = `bold ${label.length > 2 ? 22 : 28}px system-ui, sans-serif`;
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillText(label, cx, cy + 1);

    link.href = canvas.toDataURL("image/png");
  } catch {
    link.href = originalHref; // tainted canvas or failed load: leave the icon alone
  }
}

Call it from applyFallbackBadge() in badge.js alongside the title update. Stop at a small number of updates per minute. Constantly changing tab icons are distracting, which is exactly what the specification's "responsible badge usage" section warns against.

Accessibility and UX

The specification includes guidance that applies to the fallbacks as much as to the OS badge:

  • "Reserve badges for states that truly merit user attention or action; avoid using them for marketing or engagement gimmicks." A badge that never clears trains users to ignore it.
  • "Avoid rapidly changing values that increase distraction or cognitive load." Coalesce updates, as badge.js does.
  • "To support users who do not perceive badges, or have disabled them at the system level, authors are encouraged to present the same state within application UI using accessible patterns." Every badge needs an in-app equivalent: a labeled count on the inbox link, not just a colored dot.
  • User agents "SHOULD NOT automatically announce badge changes". Don't compensate with an aria-live region that announces every count change either. Update the accessible name of the in-app indicator (for example, aria-label="Inbox, 5 unread") and let users discover it.

Accessibility covers accessible notification and status patterns in more depth.

Testing and debugging

There's no DevTools panel for badges, and no API to read one back. Test at three levels:

  • By hand on each platform. Install the app, open its window, and run await navigator.setAppBadge(5) in the console. In Chromium, check the taskbar or Dock. On iOS, open the Home Screen web app, connect Safari's Web Inspector from a Mac (Develop > your device > Home Screen Web Apps), run the same call and go back to the Home Screen. If nothing shows, check that notifications are allowed, and that Badges is on, in Settings > Notifications.
  • From the service worker. Run the calls in the service worker's console (chrome://inspect/#service-workers, or Safari's Develop menu while the worker is running) to confirm the worker-side path, which is the one push uses.
  • In automated tests, as calls. Stub the methods and assert on what your code asked for. That's the part you own.
badge.spec.js (Playwright)
import { test, expect } from "@playwright/test";

test("unread count drives the app badge", async ({ page }) => {
  await page.addInitScript(() => {
    window.__badgeCalls = [];
    Object.defineProperty(Navigator.prototype, "setAppBadge", {
      configurable: true,
      value: async (n) => void window.__badgeCalls.push(n === undefined ? "flag" : n),
    });
    Object.defineProperty(Navigator.prototype, "clearAppBadge", {
      configurable: true,
      value: async () => void window.__badgeCalls.push(0),
    });
  });

  await page.route("**/api/unread-count", (route) => route.fulfill({ json: { unreadCount: 3 } }));
  await page.goto("/");

  await expect.poll(() => page.evaluate(() => window.__badgeCalls.at(-1))).toBe(3);
});

Automated Testing covers testing service worker behavior, including push handlers, in more depth.

Common pitfalls

  • Assuming a resolved promise means a visible badge. Android, Linux, uninstalled pages and iOS without notification permission all resolve silently.
  • Passing null, fractions or negative numbers. null and values below 1 clear the badge; negatives and NaN reject. Validate first.
  • Incrementing locally. Duplicate, collapsed or expired pushes make local counters drift. Send the absolute count.
  • Setting the badge but not showing a notification in a Safari push handler. That's a silent push, and Safari removes the subscription after repeated silent pushes.
  • Setting it once and forgetting it. Chromium keeps badges in memory; re-apply the count on launch.
  • Calling from a dedicated worker. Chromium only exposes the worker methods in service workers.
  • Relying on the flag form on iOS. Use an explicit number.
  • Badging from an iframe. Cross-origin frames get SecurityError (spec, WebKit) or a silent no-op (Chromium). Badge from the top-level app.
  • Expecting a badge on a closed app on Windows. Chromium draws it on open app windows' taskbar buttons.
  • No in-app equivalent. Users who disable badges, or use a platform without them, lose the information.

Browser support

Feature Chrome / Edge desktop Chrome Android Safari macOS Safari iOS / iPadOS Firefox
navigator.setAppBadge() / clearAppBadge() ✅ 81 (Windows, macOS); 91 (ChromeOS)1 ⚠️2 ✅ 17, web apps on Mac (Sonoma+) ✅ 16.4, Home Screen web apps ❌
In service workers ✅ 81 ⚠️2 ✅ 17 ✅ 16.4 (during push events) ❌
Numeric badge ✅ 99+ above 99 ❌ ✅ ✅ ❌
Flag badge (no argument) ✅ • ❌ ⚠️3 ⚠️3 ❌
Requires notification permission ⚠️4 – ⚠️5 ✅ to display –
app_badge in Declarative Web Push ❌ ❌ ✅ 18.5 ✅ 18.4 ❌

Support data as of September 2026. For live data see MDN's Navigator.setAppBadge() page and caniuse.

Further reading

On this site

External references


  1. On Linux the methods resolve without displaying anything. ↩

  2. The methods are exposed and resolve, but Android has no API path for them; launchers show a dot for unread notifications instead. ↩↩

  3. WebKit forwards the flag as a badge without a number, and Apple doesn't document how it displays. Pass an explicit count. ↩↩

  4. Not on Windows or ChromeOS. On macOS, since Chrome 152, installed apps need notification permission for the Dock badge to appear; the promise still resolves without it. ↩

  5. Web apps on Mac deliver badges through the same system notification service as notifications; request notification permission before relying on the badge. ↩