Async Payload Formatting for Native APIs
This guide is part of Native Device Integration Patterns.
When a web application hands data to the host operating system — through the Web Share API, the Clipboard API, or the File System Access API — the payload must conform to strict, platform-specific contracts. Getting the format wrong produces silent failures, TypeError rejections, or OS-level refusals that are nearly impossible to debug after the fact. This guide covers the exact sequence for constructing, validating, and dispatching asynchronous payloads so they work reliably across iOS, Android, and desktop browsers.
Why Payload Formatting Fails in Practice
Without a structured async pipeline, three failure modes appear repeatedly:
- Transient activation expiry. Fetching assets before invoking the native API burns through the browser’s user-gesture window. By the time
navigator.share()is called, the gesture has expired andNotAllowedErroris thrown. - Untyped binary data. Passing a bare
Blobwithout a filename or MIME type causes iOS Safari to reject the entirefilesarray silently. - Missing secure-context gate. In mixed-content or HTTP deployments,
navigator.sharemay appear defined in some builds yet throw immediately becausewindow.isSecureContextisfalse.
Each of these is preventable with the pattern described below.
Prerequisites Checklist
Before wiring up any native API call, verify every item in this list:
- HTTPS or
localhostorigin —window.isSecureContextmust betrue; no HTTP, no mixed content - User gesture available — execution must be triggered inside a
click,touchend, orkeydownhandler navigator.sharedefined — checktypeof navigator.share === 'function', not just truthinessnavigator.canSharedefined — present in Chromium 89+; absent in Safari 14–16 and Firefox- File MIME types declared — every
Fileobject infilesmust have an explicit, non-emptytype - No null/undefined fields — strip all keys with falsy values before passing the payload
Browser Support Snapshot
| Browser | Minimum version | navigator.share |
navigator.canShare |
File sharing |
|---|---|---|---|---|
| Chrome (Android) | 61 | Yes | 89+ | 86+ |
| Safari (iOS) | 12.1 | Yes | No | 15.1+ |
| Safari (macOS) | 12.1 | Yes | No | 15+ |
| Chrome (desktop) | 89 | Yes | 89+ | 89+ |
| Edge | 81 | Yes | 81+ | 81+ |
| Firefox | — | No | No | No |
| Samsung Internet | 8.2 | Yes | 11.1+ | 11.1+ |
Firefox ships no implementation at all; plan a full fallback path for it. Safari omits canShare, so pre-validate file types and sizes manually against the known iOS limits.
The Async Payload Pipeline: Step-by-Step
The central challenge is that asset fetching is async but navigator.share() must run synchronously within the user gesture. The solution is to complete all async work before the gesture, then invoke the API immediately inside the handler.
Step 1 — Gate on Secure Context and Feature Availability
export function isNativeShareAvailable() {
return (
window.isSecureContext &&
typeof navigator.share === 'function'
);
}
Call this check inside the event handler, not on page load. The secure context can change if the page is embedded in a non-secure iframe after initial render.
Step 2 — Snapshot Application State Before Any Async Work
Take a deep clone of the data you intend to share before any asynchronous operations begin. This snapshot is your recovery point if the OS dialog is dismissed or the fetch fails mid-flight.
const snapshot = structuredClone(shareData);
structuredClone throws synchronously for unserializable objects (DOM nodes, functions), so this line also acts as a pre-flight validation that your data is safe to pass to the native bridge.
Step 3 — Fetch and Convert Assets to Named File Objects
The Web Share API requires File instances in the files array, not raw Blob objects. A Blob without a name property is rejected outright on iOS Safari. Use Promise.all to resolve multiple assets in parallel, keeping the async window as short as possible.
const controller = new AbortController();
async function resolveFileAssets(assets) {
return Promise.all(
assets.map(async (asset) => {
const response = await fetch(asset.url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Asset fetch failed for ${asset.url}: ${response.status}`);
}
const blob = await response.blob();
// Must use File, not Blob — iOS Safari rejects bare Blobs in the files array
return new File([blob], asset.filename, { type: asset.mimeType });
})
);
}
Always attach the AbortController signal to every fetch call. When the user navigates away before the dialog resolves, you can abort the pending requests in one call rather than letting them run to completion in the background.
Step 4 — Assemble the ShareData Payload
Construct the ShareData dictionary only after all assets have resolved. Omit any key whose value is falsy — passing url: undefined or text: null produces a TypeError on some Chromium versions.
function buildPayload(data, files) {
return {
...(data.title ? { title: data.title } : {}),
...(data.text ? { text: data.text } : {}),
...(data.url ? { url: new URL(data.url).href } : {}),
...(files.length ? { files } : {}),
};
}
Wrapping data.url in new URL(...).href normalises the string and throws immediately if the URL is malformed — much better than discovering the error inside the OS dialog flow.
Step 5 — Validate with navigator.canShare() Where Available
navigator.canShare() is not universally supported, so check for it before calling it. When it is available it catches unsupported MIME types and oversized payloads before the share dialog opens.
function canSharePayload(payload) {
if (typeof navigator.canShare !== 'function') {
// Fall back to manual MIME-type and size validation
return true;
}
return navigator.canShare(payload);
}
On iOS Safari (which has no canShare), validate files manually: check that each MIME type is in the set Apple documents as shareable (image/jpeg, image/png, image/gif, video/mp4, audio/mpeg, application/pdf, and a handful of others) and that no single file exceeds ~50 MB.
Step 6 — Invoke navigator.share() and Handle DOMException
The invocation must happen in the same event-loop task as the user gesture. Pre-resolve all async work in a preparatory function that the event handler awaits before calling navigator.share().
async function shareContent(event) {
event.preventDefault();
if (!isNativeShareAvailable()) {
triggerFallbackUI(snapshot);
return;
}
try {
const files = await resolveFileAssets(data.assets ?? []);
const payload = buildPayload(data, files);
if (!canSharePayload(payload)) {
triggerFallbackUI(snapshot);
return;
}
await navigator.share(payload);
} catch (err) {
if (err.name === 'AbortError') {
// User cancelled the OS dialog or navigated away — not an error
return;
}
if (err.name === 'NotAllowedError') {
// Gesture expired or policy block — show fallback
triggerFallbackUI(snapshot);
return;
}
if (err.name === 'TypeError') {
// Malformed payload — log for debugging, show fallback
console.error('ShareData payload rejected:', err.message);
triggerFallbackUI(snapshot);
return;
}
throw err; // Unexpected — let it surface
}
}
Step 7 — Abort Pending Fetches and Reset State
Run cleanup in a finally block so it executes whether the share succeeded, failed, or was cancelled.
// Inside the finally block of shareContent
controller.abort();
// Create a fresh controller for the next invocation
const controller = new AbortController();
snapshot = null;
Without resetting the AbortController, the signal is permanently aborted after the first run and every subsequent fetch will fail immediately with AbortError.
Full Production Implementation
export class NativePayloadDispatcher {
#controller = new AbortController();
#snapshot = null;
async dispatch(data, onFallback) {
if (!window.isSecureContext) {
throw new DOMException(
'Native APIs require a secure context (HTTPS or localhost).',
'SecurityError'
);
}
if (typeof navigator.share !== 'function') {
onFallback(data);
return;
}
this.#snapshot = structuredClone(data);
try {
const files = await Promise.all(
(data.assets ?? []).map(async (asset) => {
const res = await fetch(asset.url, { signal: this.#controller.signal });
if (!res.ok) throw new Error(`Fetch failed: ${asset.url} (${res.status})`);
const blob = await res.blob();
return new File([blob], asset.filename, { type: asset.mimeType });
})
);
const payload = {
...(data.title ? { title: data.title } : {}),
...(data.text ? { text: data.text } : {}),
...(data.url ? { url: new URL(data.url).href } : {}),
...(files.length ? { files } : {}),
};
if (
typeof navigator.canShare === 'function' &&
!navigator.canShare(payload)
) {
onFallback(this.#snapshot);
return;
}
await navigator.share(payload);
} catch (err) {
if (err.name === 'AbortError') return;
if (err.name === 'NotAllowedError') { onFallback(this.#snapshot); return; }
if (err.name === 'TypeError') { onFallback(this.#snapshot); return; }
throw err;
} finally {
this.#controller.abort();
this.#controller = new AbortController();
this.#snapshot = null;
}
}
}
Payload Validation and Error Boundary
The canShare / try-catch pair is not optional — it is the primary error boundary for the entire pipeline.
| DOMException name | Cause | Recovery |
|---|---|---|
NotAllowedError |
Gesture expired, user denied, or browser policy block | Show fallback UI; do not re-prompt without a new gesture |
TypeError |
Malformed ShareData: missing required keys, unserializable value, unsupported MIME |
Log payload shape; fix before retry |
AbortError |
User dismissed the OS dialog, or AbortController.abort() was called |
Treat as cancellation; no error UI needed |
SecurityError |
Called outside a secure context | Hard failure; surface a developer-facing error in dev mode |
DataError |
File content unreadable or corrupt (Chrome 119+) | Prompt the user to re-select or re-upload the file |
The DataError variant was introduced in Chromium 119 for the file-sharing path. It does not appear in Safari. Handle it alongside TypeError in the catch block.
SVG: Async Share Dispatch Flow
The diagram below shows the decision path through the dispatcher — from the user gesture to either a successful OS dialog or a graceful fallback.
Platform Gotchas
iOS Safari
- No
navigator.canShare()— validate file types and sizes manually before callingnavigator.share(). - Bare
Blobobjects rejected — every entry infilesmust be aFileinstance with anameproperty and a non-emptytype. - File type allowlist enforced — only a limited set of MIME types can be shared via the OS sheet. Common ones:
image/jpeg,image/png,image/gif,image/webp,video/mp4,audio/mpeg,application/pdf. Others silently fail or triggerTypeError. - 15 MB practical limit — the documented cap is higher but devices with less free RAM reject larger payloads before the dialog opens.
Android / Chromium
canShare()is reliable from Chrome 89 — use it and trust the result.- Intent routing — the OS share sheet on Android routes files through Android Intents. Apps that do not declare the relevant intent filter will not appear in the sheet, regardless of MIME type.
- 50 MB ceiling — Chromium enforces this in the renderer before any OS call; payloads above this limit throw
TypeError.
Desktop Chrome and Edge
- File sharing supported from Chrome 89 — but significantly fewer apps appear in the system share sheet compared to mobile. The user experience is degraded; consider defaulting to clipboard copy on desktop.
- User gesture window — desktop browsers are more lenient about the gesture window than mobile Safari, but the window is still finite. Do not place an
awaitfor a network round-trip between the click handler and thenavigator.share()call.
Testing and Verification
DevTools checklist
- Open DevTools Application panel → check the origin is marked Secure before testing any native call.
- Open the Network panel and confirm asset fetch requests carry the
AbortControllersignal — they should cancel immediately when you navigate away mid-share. - Set a breakpoint on the
navigator.share()call and inspect thepayloadobject. Confirm allfilesentries areFileinstances (notBlob), each with a non-emptynameandtype. - Simulate
NotAllowedErrorby invokingnavigator.share()from the console (outside a gesture) and confirm your catch block triggers the fallback path.
Physical device checklist
- iOS: test on Safari — not a third-party browser; test with both image and non-image MIME types to hit the allowlist gate.
- Android: test on Chrome; confirm the share sheet populates with target apps appropriate to the MIME type.
- Desktop: test on both Chrome and Edge; confirm fallback UI renders when the share sheet is dismissed.
- HTTP environment: load the page over plain HTTP and confirm
isNativeShareAvailable()returnsfalseand fallback UI activates — never leave this path untested.
For large-file scenarios, see Handling Large File Uploads with the File System Access API for chunked upload patterns that complement the async fetch pipeline above.
Common Pitfalls
- Missing transient activation — invoking native APIs outside a direct user gesture triggers
NotAllowedError. Bind execution toclickorkeydownhandlers without intermediateawaitgaps. - Passing bare
Blobobjects infiles— thefilesarray requiresFileinstances with anameproperty. Wrap blobs:new File([blob], 'filename.jpg', { type: blob.type }). - Ignoring secure context checks — skipping
window.isSecureContextvalidation leads to silent failures in HTTP environments. - Reusing an aborted
AbortController— onceabort()is called, the signal is permanently aborted. Always construct a new instance after each dispatch cycle. - Synchronous
structuredClonefailure — if yourdataobject contains DOM node references or functions,structuredClonethrows before any async work begins. Sanitise the object first.
FAQ
How do I handle async payload formatting for large files in the Web Share API?
Use File objects — not base64 strings or raw blobs without names. Fetch streams and convert to File instances asynchronously using Promise.all. Validate total payload size against OS limits before invoking: iOS caps around 10–50 MB per file; Android/Chromium around 50 MB. Call navigator.canShare() first on platforms that support it.
What happens if the user denies permission during an async API call?
The promise rejects with NotAllowedError. Catch this specific error, trigger your fallback UI, and restore application state from the pre-invocation snapshot. Treat denials as expected user behaviour, not application failures. For strategies on surfacing this gracefully, see Handling Permission Denials Gracefully.
Is feature detection sufficient for secure context validation?
No. Feature detection only confirms API presence in the current browser build. Explicitly check window.isSecureContext because the API is restricted to HTTPS or localhost environments by browser security policy, regardless of whether navigator.share is defined.
Related
- Native Device Integration Patterns — parent section covering the full range of host OS integration APIs
- File System Access API: Read and Write Workflows — local file staging and chunked upload patterns
- Mastering the Clipboard API for Rich Text — MIME type negotiation for clipboard payloads
- Implementing navigator.canShare() for Graceful Fallbacks — deep-dive on canShare() validation
- Handling Permission Denials Gracefully — recovery patterns for NotAllowedError across all native APIs