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:

  1. 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 and NotAllowedError is thrown.
  2. Untyped binary data. Passing a bare Blob without a filename or MIME type causes iOS Safari to reject the entire files array silently.
  3. Missing secure-context gate. In mixed-content or HTTP deployments, navigator.share may appear defined in some builds yet throw immediately because window.isSecureContext is false.

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 localhost originwindow.isSecureContext must be true; no HTTP, no mixed content
  • User gesture available — execution must be triggered inside a click, touchend, or keydown handler
  • navigator.share defined — check typeof navigator.share === 'function', not just truthiness
  • navigator.canShare defined — present in Chromium 89+; absent in Safari 14–16 and Firefox
  • File MIME types declared — every File object in files must have an explicit, non-empty type
  • 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.

Async Payload Dispatch Pipeline Decision flow from user gesture through secure context and feature detection, async asset fetch with AbortController, canShare validation, navigator.share invocation, error handling by DOMException type, and fallback UI routing. User gesture isSecureContext = true? Throw SecurityError navigator.share defined? Fallback UI Fetch assets async (Promise.all + AbortController) canShare(payload) = true? Fallback UI navigator.share(payload) DOMException thrown? Share complete Handle by name (Abort/Denied/Type) Yes No Yes No Yes No No Yes

Platform Gotchas

iOS Safari

  • No navigator.canShare() — validate file types and sizes manually before calling navigator.share().
  • Bare Blob objects rejected — every entry in files must be a File instance with a name property and a non-empty type.
  • 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 trigger TypeError.
  • 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 await for a network round-trip between the click handler and the navigator.share() call.

Testing and Verification

DevTools checklist

  1. Open DevTools Application panel → check the origin is marked Secure before testing any native call.
  2. Open the Network panel and confirm asset fetch requests carry the AbortController signal — they should cancel immediately when you navigate away mid-share.
  3. Set a breakpoint on the navigator.share() call and inspect the payload object. Confirm all files entries are File instances (not Blob), each with a non-empty name and type.
  4. Simulate NotAllowedError by invoking navigator.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() returns false and 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 to click or keydown handlers without intermediate await gaps.
  • Passing bare Blob objects in files — the files array requires File instances with a name property. Wrap blobs: new File([blob], 'filename.jpg', { type: blob.type }).
  • Ignoring secure context checks — skipping window.isSecureContext validation leads to silent failures in HTTP environments.
  • Reusing an aborted AbortController — once abort() is called, the signal is permanently aborted. Always construct a new instance after each dispatch cycle.
  • Synchronous structuredClone failure — if your data object contains DOM node references or functions, structuredClone throws 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.