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 pillar, 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);
  });
}

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.

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

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.