Connectivity-Aware UX for Offline Sharing
This guide is part of Offline-First PWA Patterns for Web Share API.
An offline share queue is only half the feature. If a user taps Share on a train, watches nothing happen, and gets no feedback, they assume the app is broken and tap again — creating duplicates — or give up entirely. The queue captured their intent, but the interface never told them. This guide covers the feedback layer: how to detect connectivity accurately, how to reflect the difference between captured and sent in the UI, and how to announce those transitions to every user, including those using assistive technology.
The Offline-First PWA Patterns overview introduces the core connectivity probe. Here we go deeper into the signal sources, the state machine that consumes them, and the accessible presentation layer that sits on top.
Problem Framing
Without a connectivity-aware layer, three failures compound:
- Silent capture. The queue stores the payload but the button gives no acknowledgement, so the user cannot distinguish “saved” from “ignored”.
- False confidence.
navigator.onLinereportstruebehind a captive portal or on a stalled connection, so the app tries to share, fails, and looks broken. This is the exact gap that detecting captive portals before sharing closes. - Invisible backlog. Several shares accumulate offline, but nothing tells the user how many are waiting or when they clear. Surfacing that number is the job of showing the queued share count in the UI.
The fix is a small state machine fed by multiple signals and reflected in both a visual and an accessible channel. The actual persistence and flushing belong to background sync for deferred shares; this guide is the interface that sits above it.
Prerequisites Checklist
Browser Support Snapshot
| Signal / API | Chrome (Android) | Chrome (desktop) | Safari / iOS | Firefox | Edge |
|---|---|---|---|---|---|
navigator.onLine |
✅ | ✅ | ✅ | ✅ | ✅ |
online / offline events |
✅ | ✅ | ✅ | ✅ | ✅ |
fetch + AbortSignal.timeout |
✅ | ✅ | ✅ (16.4+) | ✅ (100+) | ✅ |
navigator.connection (NetInfo) |
✅ | ✅ | ❌ | ❌ | ✅ |
aria-live regions |
✅ | ✅ | ✅ | ✅ | ✅ |
The takeaway: the probe and the ARIA layer are universal; the Network Information API is Chromium-only and must be treated as an optional enhancement.
Step-by-Step Implementation
Step 1 — Detect the connectivity state
Start with navigator.onLine and the online / offline events, but treat them as a coarse baseline. The events fire on interface changes, not server reachability, so they belong in a model that a probe can later override.
// connectivity-state.js
export function createConnectivityModel() {
if (!window.isSecureContext) {
throw new Error('Connectivity-aware sharing requires a secure context.');
}
const listeners = new Set();
let state = navigator.onLine ? 'online' : 'offline-queued';
function setState(next) {
if (next === state) return;
state = next;
for (const fn of listeners) fn(state);
}
window.addEventListener('online', () => setState('reconnecting'));
window.addEventListener('offline', () => setState('offline-queued'));
return {
get state() { return state; },
setState,
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
};
}
The online event moves the model to reconnecting rather than straight to online — the interface is back, but reachability is unconfirmed until Step 2 succeeds.
Step 2 — Probe reachability
A live interface is not a live server. Confirm with a HEAD request to a cacheable endpoint, bounded by AbortSignal.timeout so a hung socket cannot stall the UI. Use cache: 'no-store' so a cached response never masquerades as a live one.
// reachability.js
export async function probeReachable(probeUrl = '/manifest.json', timeoutMs = 3000) {
// Fast negative: no interface means definitely offline, skip the request.
if (navigator.onLine === false) return false;
try {
const res = await fetch(probeUrl, {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs)
});
return res.ok;
} catch {
// Timeout, DNS failure, or captive-portal interception all land here.
return false;
}
}
Wire the probe into the model so reconnecting resolves to either online (drain the queue) or back to offline-queued (a captive portal or dead server).
// connectivity-controller.js
import { probeReachable } from './reachability.js';
export function attachReachabilityProbe(model, onReconnect) {
model.subscribe(async (state) => {
if (state !== 'reconnecting') return;
const reachable = await probeReachable();
if (reachable) {
model.setState('online');
await onReconnect(); // drain the queue
} else {
model.setState('offline-queued');
}
});
}
Step 3 — Reflect status in the UI
Map each state to a distinct treatment. The critical UX rule is that captured and sent are different messages: an optimistic confirmation on enqueue, a separate confirmation on successful drain.
// share-ui.js
export function renderConnectivityState(state, els) {
const { badge, banner } = els;
if (state === 'online') {
banner.hidden = true;
return;
}
if (state === 'offline-queued') {
banner.hidden = false;
banner.textContent = "You're offline — shares are saved and sent on reconnect.";
banner.dataset.tone = 'queued';
return;
}
if (state === 'reconnecting') {
banner.hidden = false;
banner.textContent = 'Back online — sending your queued shares…';
banner.dataset.tone = 'reconnecting';
}
}
export function confirmCaptured(toast, queuedCount) {
toast.textContent = `Saved to send when you reconnect (${queuedCount} queued).`;
}
export function confirmSent(toast) {
toast.textContent = 'Your queued shares were sent.';
}
Optimistic UI is appropriate here because the queue write is durable: once enqueueShare() resolves, the intent survives reload, so confirming capture immediately is honest, not a lie.
Step 4 — Announce changes accessibly
Screen reader users get none of the visual cues above. Mirror every transition into an aria-live="polite" region with role="status". Polite politeness queues the announcement without interrupting the user’s current task.
<!-- Place once in the app shell; keep it in the DOM at all times. -->
<div id="share-status" role="status" aria-live="polite" class="visually-hidden"></div>
// announce.js
export function announceConnectivity(state, regionEl) {
const messages = {
'online': 'Connection restored. Queued shares are sending.',
'offline-queued': 'You are offline. Shares will be saved and sent later.',
'reconnecting': 'Reconnecting. Checking whether the server is reachable.'
};
const text = messages[state];
if (!text) return;
// Clearing first guarantees the region is seen as changed even if the
// same message repeats, so assistive tech re-announces it.
regionEl.textContent = '';
requestAnimationFrame(() => { regionEl.textContent = text; });
}
Keep the live region in the DOM permanently — inserting it at announcement time is unreliable because assistive technology may not have registered it yet. This mirrors the contextual, expectation-setting approach from designing contextual permission prompts: tell the user what will happen before and as it happens.
Step 5 — Drain on reconnect
When the probe confirms reachability, flush the queue and update every channel: badge count, banner, toast, and live region. On iOS Safari, where Background Sync is absent, also trigger the same drain on visibilitychange so a backgrounded tab catches up when refocused.
// drain.js
import { confirmSent, renderConnectivityState } from './share-ui.js';
import { announceConnectivity } from './announce.js';
export async function drainOnReconnect(processQueue, els) {
const drained = await processQueue(); // returns count actually sent
renderConnectivityState('online', els);
announceConnectivity('online', els.region);
if (drained > 0) confirmSent(els.toast);
return drained;
}
export function bindDrainTriggers(model, processQueue, els) {
window.addEventListener('online', () => model.setState('reconnecting'));
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return;
if (navigator.onLine === false) return;
model.setState('reconnecting'); // probe runs, then drain fires
});
}
Payload Validation and Error Boundary
Before enqueuing, validate the payload so the queue never fills with unshare-able entries. Use navigator.canShare() when present, and guard every branch on secure context and feature availability.
// enqueue-guard.js
export function isShareable(payload) {
if (!window.isSecureContext) return false;
if (typeof navigator.share !== 'function') return false;
if (typeof navigator.canShare === 'function') {
return navigator.canShare(payload);
}
// No canShare: accept text/url payloads, reject bare file arrays we cannot verify.
return Boolean(payload.text || payload.url || payload.title);
}
When a drain attempt runs, branch on the DOMException name so the UI reacts correctly:
| DOMException | Meaning in the drain loop | UI response |
|---|---|---|
AbortError |
User dismissed the native sheet mid-drain | Leave entry pending; do not announce “sent” |
NotAllowedError |
Gesture went stale, or policy blocked the call | Surface a manual “Retry share” button; keep the badge |
TypeError |
Payload invalid (bad URL scheme, unsupported files) | Mark failed; remove from the pending count |
DataError |
File payload failed serialisation/reconstruction | Mark failed; announce a single non-blocking error |
Because a drain can fire from a service worker sync where no gesture exists, NotAllowedError is common on reconnect. Treat it as “needs a fresh tap” rather than a hard failure.
Platform Gotchas
iOS Safari has no navigator.connection. Any code reading effectiveType or saveData must feature-detect first (if ('connection' in navigator)) and fall back to the probe. iOS also lacks Background Sync, so the visibilitychange drain in Step 5 is the primary reconnect mechanism, not an enhancement.
online events lie on desktop. A laptop that keeps its Wi-Fi association after the router loses upstream connectivity never fires offline. navigator.onLine stays true. Only the active probe catches this, which is why Step 2 gates the reconnecting → online transition rather than trusting the event.
Android Chrome throttles background timers. Do not poll the probe on a tight setInterval — the browser deprioritises timers in backgrounded tabs, so a poll-based reconnect can lag minutes behind reality. Drive reconnection from the online event and visibilitychange instead, using the probe only to confirm.
Save-Data is a user preference, not a connectivity state. navigator.connection.saveData === true means the user asked to conserve data; it does not mean they are offline. Use it to defer large file shares or skip an optional preview fetch, never to suppress a queued badge.
Testing and Verification
DevTools checklist:
- Open the Network panel, set Throttling to Offline, and trigger a share. Confirm the banner reads the offline-queued copy and the badge increments.
- Switch Throttling to Slow 3G. Confirm the model passes through
reconnecting(probe in flight) before landing ononline. - In the Network conditions drawer, toggle offline on and off rapidly to simulate a flapping train connection. Confirm the UI never shows “sent” while still unreachable.
- Use Application → Service Workers → Offline to verify the probe fails closed (returns
false) when the interface reports online but no request completes. - Run an accessibility audit or a screen reader (VoiceOver, NVDA) and confirm each transition is announced once, politely, without interrupting.
Physical device checklist:
FAQ
Why is navigator.onLine not enough to decide whether a share can be sent?
navigator.onLine only reflects whether the device has an active network interface, not whether your server is reachable. It reports true behind captive portals, on dead Wi-Fi, and on connections that drop every request. Treat it as a fast negative signal — false means definitely offline — and confirm positives with an active reachability probe.
Should I use the Network Information API to gate sharing?
Use it only as a hint, never as a gate. navigator.connection.effectiveType and saveData are unavailable in Safari and Firefox, so any logic that depends on them must degrade gracefully. They are useful for deferring large file shares on 2g or when save-data is on, but the authoritative signal is still an actual probe request.
How do I tell the user their share was captured versus actually sent?
Use two distinct confirmations. On enqueue while offline, show an optimistic captured message such as “Saved to send when you reconnect” and increment a queued badge. On successful drain, show a separate sent confirmation and decrement the badge. Never reuse the same toast for both, or users cannot tell pending from delivered.
How do I announce connectivity changes to screen reader users?
Render an element with aria-live="polite" and role="status", and write the new state text into it on every transition. Polite politeness queues the announcement without interrupting the user. Avoid aria-live="assertive" for routine connectivity changes because it interrupts whatever the user is doing.
How can I test the offline-queued and reconnecting states locally?
In Chrome DevTools use the Network panel Throttling dropdown to select Offline, trigger a share, and confirm the UI shows the queued state. Switch back to No throttling or a Slow 3G profile to observe the reconnecting-to-sent transition. The Service Workers panel Offline checkbox and the Network conditions drawer let you simulate flapping connectivity.