Showing the Queued Share Count in the UI

This guide is part of Connectivity-Aware Share UX, which itself is part of Offline-First PWA Patterns for Web Share API.

When shares pile up offline, a single number — “3 queued” — is the difference between a user trusting the app and tapping Share three more times because nothing seemed to happen. This guide covers how to derive that number cheaply from IndexedDB, keep it in sync as entries enqueue and drain, announce it to assistive technology, and mirror it onto the installed app icon with the App Badging API. It builds directly on the queue schema from the offline-first PWA guide, which already gives every entry a status field and a matching index.

The count has three jobs, and each wants a different surface. Sighted users need a visible chip near the share control so they can glance at it. Screen reader users need the same information spoken as it changes, without being yanked away from their current task. And users who installed the PWA benefit from seeing the number on the app icon even when the tab is closed. The hard part is not any one of these surfaces — it is keeping all three consistent with the real queue state as entries are added in the foreground and removed by a service worker in the background.

Feature Detection Gate

The count and its in-page badge only need IndexedDB and a secure context. The App Badging API is a separate, optional enhancement — gate it independently so its absence never blocks the visible count.

// badge-support.js
export function canCountQueue() {
  return window.isSecureContext === true && 'indexedDB' in window;
}

export function canSetAppBadge() {
  return (
    window.isSecureContext === true &&
    typeof navigator.setAppBadge === 'function'
  );
}

Solution Walkthrough

Step 1 — Count entries by the status index

Loading every record just to read .length deserialises every payload, including any ArrayBuffer file blobs — wasteful and slow. Instead call count() on the status index with a key range, which returns only the number.

// queue-count.js
import { openShareDB } from './share-db.js';

export async function countPending() {
  const db = await openShareDB();

  return new Promise((resolve, reject) => {
    const tx = db.transaction('queue', 'readonly');
    const index = tx.objectStore('queue').index('status');
    const req = index.count(IDBKeyRange.only('pending'));
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}
One Count, Three Surfaces A single count derived from the status index in IndexedDB is published to three consumers: the visible badge in the interface, a polite live region for assistive technology, and the installed app icon through the Badging API. index('status') .count('pending') visible badge — "3 queued" rendered from the count, never incremented locally role="status" live region announced politely, never stealing focus navigator.setAppBadge(count) installed apps only — clearAppBadge at zero

Step 2 — Update the badge on enqueue and drain

Recompute the count after every mutation and render it. Only write to the DOM when the value changed, and hide the badge entirely at zero so an empty queue leaves no residue.

// badge-render.js
import { countPending } from './queue-count.js';

let lastRendered = null;

export async function refreshBadge(badgeEl) {
  const count = await countPending();
  if (count === lastRendered) return count;   // no-op: prevents flicker
  lastRendered = count;

  if (count === 0) {
    badgeEl.hidden = true;
    badgeEl.textContent = '';
  } else {
    badgeEl.hidden = false;
    badgeEl.textContent = String(count);
    badgeEl.setAttribute(
      'aria-label',
      `${count} share${count === 1 ? '' : 's'} queued and waiting to send`
    );
  }
  return count;
}

Call refreshBadge() at the end of enqueueShare() and again after each drain iteration. Because the reconnect and drain flow lives in connectivity-aware share UX, the badge and the connectivity banner update from the same events.

Step 3 — Announce the count politely

A visual badge is invisible to screen reader users. Mirror the count into an aria-live="polite" region so each change is spoken without interrupting the current task. Clear the region before writing so a repeated value is still perceived as a change.

// badge-announce.js
export function announceCount(count, regionEl) {
  const message =
    count === 0
      ? 'All shares sent. Nothing queued.'
      : `${count} share${count === 1 ? '' : 's'} queued.`;

  regionEl.textContent = '';
  requestAnimationFrame(() => { regionEl.textContent = message; });
}

Step 4 — Mirror the count onto the app icon

For installed PWAs, navigator.setAppBadge(count) puts the number on the home-screen or taskbar icon, and navigator.clearAppBadge() removes it. Both are Promise-based and must be feature-gated; treat any rejection as non-fatal.

// app-badge.js
import { canSetAppBadge } from './badge-support.js';

export async function syncAppBadge(count) {
  if (!canSetAppBadge()) return;   // Firefox / older iOS — in-page badge already covers it

  try {
    if (count > 0) {
      await navigator.setAppBadge(count);
    } else {
      await navigator.clearAppBadge();
    }
  } catch {
    // Some platforms reject when uninstalled or backgrounded — ignore safely.
  }
}

Because a background sync drain runs in the service worker, call navigator.setAppBadge() from the worker too after it flushes entries, and postMessage the new count to any open page so the in-page badge reconciles.

// badge-sync-bridge.js — reconcile the foreground badge with SW drains
import { refreshBadge } from './badge-render.js';
import { announceCount } from './badge-announce.js';

export function listenForQueueUpdates(badgeEl, regionEl) {
  if (!('serviceWorker' in navigator)) return;

  navigator.serviceWorker.addEventListener('message', async (event) => {
    if (event.data?.type !== 'queue-updated') return;
    const count = await refreshBadge(badgeEl);
    announceCount(count, regionEl);
  });

  // Also reconcile whenever the page regains focus after a background drain.
  document.addEventListener('visibilitychange', async () => {
    if (document.visibilityState !== 'visible') return;
    const count = await refreshBadge(badgeEl);
    announceCount(count, regionEl);
  });
}

This closes the loop that causes the most common bug in queued-count UIs: the service worker empties the queue while the tab sits in the background, and the badge keeps showing a number that no longer reflects reality until the next manual interaction.

Why the Count Must Be Recomputed Incrementing a local counter drifts as soon as a second tab enqueues or the service worker drains an entry, leaving the badge showing a number no store agrees with. Recounting from the status index after every mutation keeps every surface consistent. local counter++ tab A enqueues → badge 1 tab B enqueues → its badge 1 worker drains one → neither knows badges disagree with the database users see a number that never clears recount from the index every mutation triggers a count count() on an index is O(index) broadcast to other tabs one source of truth, always badge and database cannot diverge

Failure Modes and Recovery

Failure Symptom Minimal fix
Stale count after background drain Badge still shows “3” though the queue is empty Recompute on visibilitychange; have the SW postMessage the count after draining
App Badging API unsupported navigator.setAppBadge is not a function thrown Gate with canSetAppBadge(); rely on the in-page badge and aria-live
Count flicker Badge flashes intermediate numbers during a batch drain Coalesce updates; only touch the DOM when the value actually changed
Counting by getAll().length Slow badge refresh with large file entries Use the status index count() instead of loading records
Badge left visible at zero “0” chip lingers after the queue clears Set hidden = true and clear textContent when count === 0
Where the App Icon Badge Appears The Badging API only affects an installed application. In a browser tab the calls are harmless no-ops, so the in-page badge and the live region remain the only surfaces the user sees, and neither depends on the API being present. installed PWA setAppBadge paints a dot or count on the icon visible on the home screen with the app closed browser tab no icon to badge — the call is a harmless no-op guard with a typeof check and move on what every user gets regardless the in-page badge and the polite live region — both derived from the same recount the icon badge is an enhancement, never the only place the count appears

Browser and Platform Caveat

The IndexedDB count and the in-page badge work everywhere the queue does — every browser with a secure context. The App Badging API is the variable: it is supported in Chrome and Edge on desktop and, for installed PWAs, on Android and (since iOS 16.4) on iOS Safari, but it is absent in Firefox. It also only shows on installed apps, so a browser tab will silently no-op even where the API exists. That is why the icon badge is strictly additive: the authoritative, universally visible count is the in-page badge plus the aria-live announcement, and the app-icon badge is a bonus for users who installed the PWA.

FAQ

How do I count pending shares without loading every record?

Create an index on the status field when you define the object store, then call index('status').count(IDBKeyRange.only('pending')). count() returns just the number and never deserialises the payloads, so it stays fast even with large file entries in the queue.

Why is my badge count stale after a background sync drain?

A Background Sync drain runs in the service worker, which cannot touch the DOM, so the foreground badge never hears about it. Have the service worker postMessage the new count after draining, and recompute the badge whenever the page regains visibility, so the UI reconciles with the real queue state.

What do I do when the App Badging API is unsupported?

Feature-detect navigator.setAppBadge and treat it as an enhancement only. When it is missing — Firefox and, until recently, iOS — fall back to the in-page numeric badge and the aria-live announcement, which work everywhere. Never let the icon badge be the only place the count appears.

How do I stop the badge count from flickering?

Recompute once after a batch of changes rather than per entry, and only touch the DOM when the number actually differs from what is rendered. Coalescing rapid enqueue and drain events into a single render pass with requestAnimationFrame removes the flicker of intermediate values.