SMS and Email Fallback Architectures for the Web Share API

This guide is part of Permission Flows & Progressive Enhancement.

When the Web Share API is unavailable — because the browser does not implement it, the page is not in a secure context, or the user has denied the permission — mailto: and sms: URI schemes are the most reliable cross-platform bridges. Without a deliberate fallback architecture, those share buttons silently do nothing, and users on desktop Firefox, non-Chromium browsers, or locked-down enterprise environments are left with no path forward.

The diagram below shows the complete decision flow from a button click to a successful share action or graceful fallback.

Share Fallback Decision Flow Flowchart showing the decision path from a user clicking Share through secure context and feature detection checks, native API invocation, error type branching, and finally SMS or email fallback URI construction. User clicks Share Secure context & API present? navigator.share() Error name? AbortError? ✓ Native share Do nothing Mobile UA? sms: URI mailto: URI Route to fallback No Yes OK Error Yes No Yes No

Problem Framing

A share button that silently fails is worse than no share button at all. The failure surface is larger than most developers expect:

  • No API support: Firefox on desktop, Samsung Internet older than v11, and most in-app WebViews do not implement navigator.share.
  • Non-secure context: Any http:// page — including localhost without explicit HTTPS — will have window.isSecureContext === false, causing the API to be absent entirely.
  • Permission denial: On iOS 17+ and Chrome on Android, the browser can silently block share attempts if the call does not originate from a user gesture or if the tab is backgrounded.
  • Payload rejection: Sharing files that fail navigator.canShare() throws a TypeError before the sheet even appears.

mailto: and sms: URI schemes handle all four cases: they are gesture-free on most platforms, work in non-secure contexts, require no permission, and carry arbitrary text payloads.

Prerequisites Checklist

Before wiring up fallbacks, confirm:

Browser Support Snapshot

Browser / Platform navigator.share sms: URI mailto: URI
Chrome for Android 75+
Safari iOS 12.2+ ✅ (ampersand separator)
Chrome Desktop 89+ ✅ (files only in 86+) ❌ (no SMS app)
Firefox Desktop (all)
Firefox for Android 79+
Samsung Internet 11+
Edge Desktop 93+
Safari macOS 12+ ❌ (no default SMS app)
In-app WebViews ❌ (most) Varies

The key insight: mailto: is the only universally safe fallback across every environment. sms: is appropriate only when you have strong signal that the user is on a mobile device.

Step-by-Step Implementation

Step 1 — Validate secure context and API availability

Run this check before any other code runs. Do not assume the API is present because you are in a modern browser.

// share-guards.js
export function canUseNativeShare() {
  return (
    window.isSecureContext === true &&
    typeof navigator.share === 'function'
  );
}

export function canSharePayload(payload) {
  if (!canUseNativeShare()) return false;
  if (typeof navigator.canShare !== 'function') return true; // older browsers without canShare
  return navigator.canShare(payload);
}

navigator.canShare() is available in Chrome 89+, Safari 15.1+, and Firefox for Android 79+. For browsers that expose navigator.share but not navigator.canShare, fall back to treating the payload as valid and catching any TypeError during the actual call.

Step 2 — Compose and validate the share payload

Map your content to the Web Share API’s schema (title, text, url). Trim whitespace and enforce length limits before constructing any URI to avoid malformed drafts in SMS or email clients.

// payload-builder.js
const SMS_BODY_LIMIT = 155; // leave room for URL
const MAILTO_BODY_LIMIT = 1800;

export function buildSharePayload({ title = '', text = '', url = window.location.href }) {
  const trimmedText = String(text).trim();
  const trimmedUrl = String(url).trim();

  if (!trimmedText && !trimmedUrl) {
    throw new TypeError('Share payload requires at least text or url.');
  }

  return { title: String(title).trim(), text: trimmedText, url: trimmedUrl };
}

export function buildSmsBody(text, url) {
  const full = `${text} ${url}`.trimStart();
  return full.length > SMS_BODY_LIMIT ? url : full;
}

export function buildMailtoBody(text, url) {
  const full = `${text}\n\n${url}`;
  return full.length > MAILTO_BODY_LIMIT ? `${full.slice(0, MAILTO_BODY_LIMIT - 3)}...` : full;
}

Step 3 — Attempt native share and intercept errors

Use async/await with explicit error-name branching. AbortError means the user deliberately dismissed the sheet — do not redirect to a fallback channel. Everything else warrants fallback routing.

// share-controller.js
import { canSharePayload, buildSharePayload } from './share-guards.js';
import { buildSmsBody, buildMailtoBody } from './payload-builder.js';

export async function executeShare(rawPayload) {
  const payload = buildSharePayload(rawPayload);

  if (canSharePayload(payload)) {
    try {
      await navigator.share(payload);
      trackShare('native_success');
      return;
    } catch (error) {
      if (error.name === 'AbortError') {
        // User dismissed — this is not a failure; do not route to fallback
        trackShare('native_dismissed');
        return;
      }
      // NotAllowedError, DataError, TypeError, or unknown — fall through
      trackShare('native_error', { errorName: error.name });
    }
  }

  // Fallback routing
  routeToFallback(payload.text, payload.url);
}

function routeToFallback(text, url) {
  const isMobileUA = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
  const uri = isMobileUA ? buildSmsUri(text, url) : buildMailtoUri(text, url);

  trackShare('fallback_initiated', { channel: isMobileUA ? 'sms' : 'email' });
  window.location.href = uri;
}

Step 4 — Build the sms: URI with platform-correct separators

The iOS and Android sms: URI formats use different query separators. iOS requires sms:&body=TEXT; Android uses sms:?body=TEXT. The ampersand form is the safest default because Android accepts both, while iOS does not accept the question-mark form.

// uri-builders.js
export function buildSmsUri(text, url) {
  // No phone number target — open the new-message composer
  // iOS: sms:&body=...  |  Android: sms:?body=... (& also works on Android)
  const body = encodeURIComponent(buildSmsBody(text, url));
  return `sms:&body=${body}`;
}

export function buildMailtoUri(text, url) {
  const subject = encodeURIComponent('Check this out');
  const body = encodeURIComponent(buildMailtoBody(text, url));
  return `mailto:?subject=${subject}&body=${body}`;
}

Never skip encodeURIComponent. Unencoded ampersands, equals signs, or line breaks in the body will silently truncate the draft or cause the URI to fail to open on some clients.

Step 5 — Wire the controller to a button

Bind the share call directly to the button’s click event. Wrapping it in a setTimeout or requestAnimationFrame breaks the user-gesture chain and can cause browsers to reject both the native API call and the window.location.href assignment.

// share-button.js
import { executeShare } from './share-controller.js';

export function initShareButton(buttonSelector, shareData) {
  const button = document.querySelector(buttonSelector);
  if (!button) return;

  button.addEventListener('click', async (event) => {
    event.preventDefault();
    button.setAttribute('aria-busy', 'true');

    try {
      await executeShare(shareData);
    } finally {
      button.removeAttribute('aria-busy');
    }
  });
}

Payload Validation and Error Boundary

Use navigator.canShare() as a pre-flight check when sharing files. For text-only payloads, canShare mostly returns true if the API is present, but running it explicitly lets you catch malformed url values (non-HTTP schemes that Safari will reject) before the share attempt.

// validation.js
export function validateUrlScheme(url) {
  try {
    const parsed = new URL(url);
    // Web Share API only accepts http: and https: URLs on most platforms
    if (!['http:', 'https:'].includes(parsed.protocol)) {
      throw new TypeError(`Unsupported URL scheme: ${parsed.protocol}`);
    }
    return parsed.href;
  } catch {
    throw new TypeError(`Invalid share URL: ${url}`);
  }
}

For the mailto: fallback, Safari on iOS silently ignores excessively long body parameters. Keep mailto: bodies under 2000 characters and SMS bodies under 160 characters to stay within single-message limits. The buildSmsBody helper in Step 2 handles this by falling back to the URL alone when the combined text-plus-URL string exceeds the limit.

Errors you will encounter and their correct handling:

DOMException Cause Correct response
AbortError User dismissed the sheet Do nothing — no fallback
NotAllowedError No user gesture on call stack, or permission denied Route to fallback
TypeError Invalid payload (bad URL scheme, missing required field) Fix payload or route to fallback
DataError File type not in canShare allowlist Route to text-only fallback

Platform Gotchas

iOS sms: separator. The definitive rule: iOS requires sms:&body= (ampersand) rather than sms:?body= (question mark). Using a question mark on iOS opens the messages app but leaves the body blank. Details are covered in depth in Building a resilient SMS fallback for unsupported browsers.

Android window.location.href vs window.open. On Android Chrome, window.location.href = 'sms:...' reliably opens the default messages app. window.open('sms:...', '_blank') is often blocked by the pop-up blocker even within a user gesture. Prefer location.href assignment.

Desktop Chrome + macOS Safari. Both support navigator.share on the desktop, but sms: has no default handler unless the user has linked their phone (Handoff on macOS; no equivalent on Windows/Linux). Always route desktop user agents to mailto: regardless of whether native share succeeded, unless you have confirmed a mobile UA string. See the QR code generation fallback for cross-device desktop scenarios where even mailto: is insufficient.

In-app WebViews (Instagram, TikTok, Facebook). Most in-app browsers strip navigator.share entirely and may also intercept window.location.href changes, preventing sms: navigation. For WebView detection, check for the absence of navigator.share alongside window.safari === undefined and a mobile UA string — this combination reliably identifies social media WebViews on iOS. On Android, navigator.userAgent often contains wv for WebView builds.

Permission handling on iOS 17+. Apple added background-tab restrictions that can silently fail navigator.share calls made when the tab is not in the foreground. The resulting error is NotAllowedError. Implementing Handling Permission Denials Gracefully — including the exponential-backoff re-prompt pattern — prevents repeated futile API calls in these environments.

Enterprise browsers and strict CSP. Corporate devices running managed Edge or Chrome often have Content-Security-Policy: default-src 'none' or a navigate-to directive that blocks sms: and mailto: schemes. There is no programmatic way to detect this restriction before attempting navigation. The best mitigation is to show a copy-to-clipboard fallback alongside the URI-based fallbacks, so users in those environments can still get the content.

Progressive Web Apps installed to home screen. PWAs in standalone display mode on Android cannot reliably open sms: URIs in Chrome 115+ due to an intentional restriction on cross-app navigation from standalone PWAs. Use window.open(uri, '_blank') — which navigates to the system browser first — as the preferred method for URI fallbacks inside installed PWAs.

Testing and Verification

DevTools checklist:

  1. Open the Application panel → clear site data, then reload. Confirm window.isSecureContext === true in the console.
  2. In the Network conditions panel, set UA to “No override” (default) and trigger the share path. Confirm the native sheet appears.
  3. Change the UA to a Firefox Desktop string and trigger again. Confirm canUseNativeShare() returns false and window.location.href is set to a mailto: URI (visible in the Sources panel’s watch expressions or a breakpoint before the assignment).
  4. Set UA to an iPhone string, trigger again. Confirm the URI starts with sms:&body=.

Physical device checklist:

Analytics verification: After deploying, check that share_native_success, share_native_dismissed, share_fallback_initiated events arrive in your telemetry with the correct channel property. A ratio of >30% fallback initiations on desktop is expected and normal; >30% on mobile may indicate a gesture-binding issue.

FAQ

How do I tell user cancellation apart from API unavailability?

Check error.name. An AbortError means the user dismissed the native share sheet — do not trigger a fallback. Any other DOMException (TypeError, NotAllowedError, DataError) signals a system-level failure and warrants routing to SMS or email. API unavailability never throws at all — canUseNativeShare() returns false before the call is made.

Are sms: URI schemes universally supported on iOS and Android?

Both platforms support the scheme, but the body parameter separator differs. iOS requires sms:&body=TEXT (ampersand); Android uses sms:?body=TEXT (question mark). The ampersand form is the safer default because Android also accepts it, while iOS does not accept the question-mark form. Always encode the body with encodeURIComponent and test on real hardware — emulators and simulators do not reliably open sms: URIs.

How do I track fallback events without violating GDPR or CCPA?

Track aggregated, non-PII event names such as share_fallback_sms or share_fallback_email. Never log message content or recipient details. Send telemetry with navigator.sendBeacon('/api/telemetry', JSON.stringify({ event, channel })) so the beacon fires asynchronously and does not block navigation to the fallback URI.