Building a resilient SMS fallback for unsupported browsers
This guide is part of SMS and Email Fallback Architectures, which sits within Permission Flows & Progressive Enhancement.
When navigator.share() is unavailable, blocked by a strict browser security policy, or dismissed by the user, frontend code needs a deterministic path to the sms: URI scheme. Silent navigation failures, URI truncation, and main-thread blocking during payload construction degrade PWA reliability and break mobile-web handoff flows. This page isolates the exact error states, cross-browser parsing quirks, and memory-safe construction patterns that make an SMS fallback robust in production.
Problem statement
Three intersecting issues cause SMS fallback routing to fail silently: unhandled DOMException states from navigator.share(), platform-specific URI parsing rules for the sms: scheme, and synchronous heap allocation during string encoding that exceeds safe byte limits.
Without deliberate error-name branching, fallback code either fires on user dismissals (where no fallback is appropriate) or skips entirely when navigator.share() is absent. The full SMS and email fallback architecture covers the complete decision tree; this page focuses on the SMS-specific construction and platform quirks.
Feature detection gate
Run this before any payload construction or URI assignment. Every subsequent step depends on the context being secure and the check confirming this is not a user-dismissed share.
// sms-gate.js
export function isSecureCtx() {
return window.isSecureContext === true;
}
export function isSmsEligible(errorName) {
// AbortError means the user dismissed the native share sheet.
// Do not route to SMS in that case — the user chose not to share.
// All other error names (NotAllowedError, TypeError, unavailable) warrant fallback.
return errorName !== 'AbortError';
}
export function isMobileUA() {
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? '');
}
Solution walkthrough
Step 1 — Validate UTF-8 byte length before URI construction
iOS Safari truncates sms: payloads beyond roughly 1900 UTF-8 bytes; Android Chrome around 2000 characters. Measure with TextEncoder, which counts actual encoded bytes rather than JavaScript string length (a distinction that matters for emoji and non-ASCII characters).
// payload-validator.js
const MAX_SMS_BYTES = 1900;
export function validateSmsPayload(body) {
if (!body || typeof body !== 'string') {
throw new TypeError('SMS body must be a non-empty string.');
}
const byteLength = new TextEncoder().encode(body).length;
if (byteLength > MAX_SMS_BYTES) {
throw new RangeError(
`SMS payload is ${byteLength} bytes — exceeds safe limit of ${MAX_SMS_BYTES}.`
);
}
return body;
}
When the payload is too long, truncate to a URL-only body rather than silently sending a garbled message. If you use a server-side URL shortener, shorten first and then re-validate.
Step 2 — Construct an RFC 3986-compliant sms: URI
iOS and Android parse the sms: scheme differently. iOS requires an ampersand separator (sms:&body=TEXT); Android uses a question mark (sms:?body=TEXT). The ampersand form is accepted by both platforms, making it the correct default. Always use encodeURIComponent and replace any + with %20 — WebKit’s legacy query parser may interpret + as a literal plus sign rather than a space.
// sms-uri-builder.js
export function buildSmsUri(body) {
// encodeURIComponent encodes spaces as +; replace with %20 for WebKit compatibility
const encodedBody = encodeURIComponent(body).replace(/\+/g, '%20');
// Ampersand separator works on both iOS and Android; question mark fails on iOS
return `sms:&body=${encodedBody}`;
}
Step 3 — Assign the URI synchronously inside a user-gesture handler
iOS Safari enforces a gesture gate: window.location.href assignments for custom URI schemes must happen synchronously on the same call stack as the originating click or touchend event. Deferring via setTimeout, requestAnimationFrame, or after an await boundary breaks the gesture chain and the SMS app will not open.
// share-button.js
import { isSecureCtx, isSmsEligible, isMobileUA } from './sms-gate.js';
import { validateSmsPayload } from './payload-validator.js';
import { buildSmsUri } from './sms-uri-builder.js';
export function initShareButton(buttonSelector, shareData) {
const button = document.querySelector(buttonSelector);
if (!button) return;
button.addEventListener('click', async (event) => {
event.preventDefault();
if (!isSecureCtx()) return;
const { title = '', text = '', url = window.location.href } = shareData;
const body = [title, text, url].filter(Boolean).join('\n');
// Attempt native share first
if (typeof navigator.share === 'function' && navigator.canShare?.(shareData)) {
try {
await navigator.share(shareData);
return;
} catch (err) {
if (!isSmsEligible(err.name)) return; // AbortError — user dismissed, stop here
// Fall through to URI fallback for NotAllowedError, TypeError, etc.
}
}
// Route mobile to SMS, desktop to mailto:
// This assignment must happen synchronously (no await before it on iOS)
if (isMobileUA()) {
try {
const safeBody = validateSmsPayload(body);
window.location.href = buildSmsUri(safeBody);
detectSmsHandoff();
} catch (rangeErr) {
// Payload too long — fall back to URL only
window.location.href = buildSmsUri(url);
detectSmsHandoff();
}
} else {
const encoded = encodeURIComponent(body);
window.location.href = `mailto:?body=${encoded}`;
}
});
}
Step 4 — Detect SMS handoff success
After assigning an sms: URI, attach a one-time visibilitychange listener to detect whether the native messaging app opened. When the tab loses focus (document.hidden === true), the handoff likely succeeded. A setTimeout handles the opposite case — if the tab stays active after a short delay, the URI failed to resolve.
// sms-handoff.js
export function detectSmsHandoff(onSuccess, onFailure, timeoutMs = 3000) {
let resolved = false;
document.addEventListener(
'visibilitychange',
() => {
if (document.hidden && !resolved) {
resolved = true;
onSuccess?.();
sessionStorage.setItem('sms_handoff_ts', Date.now().toString());
}
},
{ once: true }
);
setTimeout(() => {
if (!resolved) {
resolved = true;
onFailure?.();
}
}, timeoutMs);
}
The { once: true } option prevents listener accumulation across multiple share attempts. Import detectSmsHandoff in Step 3 where the comment references it.
Failure modes and recovery
| Error / symptom | Cause | Fix |
|---|---|---|
| Blank tab opens on desktop | No SMS intent handler; sms: scheme has no desktop app |
Gate on isMobileUA() — route desktop to mailto: |
| SMS app opens but body is blank (iOS) | Question-mark separator used: sms:?body= |
Use sms:&body= (ampersand) |
Body has literal + characters instead of spaces |
encodeURIComponent not post-processed |
Replace + with %20 after encodeURIComponent |
| SMS app does not open on iOS despite correct URI | window.location.href assigned after await |
Move assignment synchronously before any await boundary |
| URI silently truncated, message is garbled | Payload exceeds ~1900 UTF-8 bytes | Validate with TextEncoder and truncate to URL-only body |
AbortError triggers fallback loop |
User dismissed the native sheet | Check err.name === 'AbortError' and return early — no fallback needed |
When navigator.onLine is false, skip URI routing entirely. Serialise the payload to IndexedDB via structuredClone() and re-attempt via Background Sync when connectivity restores.
Browser and platform caveat
The sms: URI scheme works reliably on iOS Safari 12.2+ and Chrome for Android 75+. Desktop browsers — including Chrome, Firefox, Edge, and Safari on macOS — have no default SMS handler. Assigning an sms: URI on desktop either opens a blank tab or does nothing. The isMobileUA() guard in Step 3 prevents this, but user-agent strings can be spoofed; treat the desktop mailto: route as the universally safe path. Installed PWAs on Android Chrome 115+ face an additional restriction: standalone-mode apps cannot reliably open cross-app sms: intents via window.location.href. Use window.open(uri, '_blank') inside an installed PWA to force the intent through the system browser first. For designing the surrounding permission prompt UI to minimise re-prompt friction, see the contextual prompts guide.
FAQ
Why does my SMS fallback open a blank tab on desktop Chrome instead of routing to a messaging app?
Desktop browsers have no native SMS intent handler. Detect the platform via navigator.userAgent and route desktop visitors to a mailto: URI or the QR code fallback instead.
How do I prevent iOS Safari from silently dropping the body parameter?
Use sms:&body=ENCODED_TEXT with & as the separator and %20 for spaces. Avoid +-encoded spaces — WebKit’s legacy query parser may strip them.
What is the maximum safe payload length?
Android Chrome typically truncates around 2000 characters; iOS Safari around 1900 UTF-8 bytes. Validate with TextEncoder before URI construction. For longer content, shorten the URL with a server-side URL shortener or use the QR code generation fallback.
How do I detect whether the user successfully opened the native SMS app?
Listen for visibilitychange with { once: true } immediately after assigning the sms: URI. When document.hidden becomes true, the SMS app likely opened. Combine with a setTimeout to detect cases where the URI failed to resolve and the tab stays active.
Related
- SMS and Email Fallback Architectures — parent guide covering the full
sms:andmailto:decision tree - Syncing Offline Share Queues with Service Workers — persist and retry payloads when the device is offline
- QR Code Generation for Cross-Device Sharing — desktop fallback when SMS is not available