Sharing Files and Media Payloads

This guide is part of Native Device Integration Patterns.

Sharing text and a URL is close to universal. Sharing files is a different feature with its own support matrix, its own validation call, and its own failure mode that arrives after the share sheet is already open. A payload that works perfectly on an Android phone can be rejected on desktop Safari, accepted by Mail and refused by a messaging app, or fail silently because the array contained Blob objects instead of File objects.

The discipline that makes file sharing reliable is narrow: build real File objects, validate the exact payload with navigator.canShare(), do the expensive preparation before the user gesture, and treat DataError as an expected outcome rather than a crash.

File Share Pipeline Media sources — a canvas, a fetched blob, and a file input — converge on a File construction step. The resulting payload is validated by canShare. A valid payload goes to navigator.share and then to the OS sheet, where a target may still raise DataError; an invalid payload routes to a download or link fallback. canvas.toBlob() fetch → blob() <input type=file> new File([...]) name + explicit type canShare ({ files })? true share() OS sheet target chosen false Fallback: download or share the link same route for a DataError from the target DataError Prepare before the gesture encoding and fetching must not delay share()

What Breaks Without This Pattern

Four failures account for nearly every file-sharing bug report.

A Blob where a File was required. canvas.toBlob() and response.blob() both hand you a Blob, and navigator.share({ files: [blob] }) throws a TypeError because the receiving app has no filename to use. The fix is one wrapper call, but the error message rarely says so.

Validation against a placeholder. Code that calls navigator.canShare({ files: [] }), or validates a tiny probe file and then shares a 30 MB video, has validated nothing. Support is evaluated for the payload you pass.

Preparation inside the gesture. Encoding a canvas, fetching an asset, or resizing an image before calling share() consumes the user-activation window, and the call fails with NotAllowedError on Safari. Prepare first, share second.

Assuming canShare is a guarantee. It reports what the browser believes is shareable. The target the user picks applies its own rules, and a rejection there surfaces as DataError after the sheet has already opened — covered in depth by resolving DataError when sharing files.

Prerequisites

  • A secure context; file sharing is unavailable on insecure origins exactly as text sharing is.
  • navigator.canShare present — older Safari shipped share without it, so a typeof guard is mandatory.
  • Files small enough to survive the target: keep images under a couple of megabytes where possible.
  • Explicit MIME types on every File; an empty type is the most common cause of a silent rejection.

Browser Support Snapshot

Platform Files supported Practical notes
Chrome on Android Yes Broadest type support; multiple files accepted
Safari on iOS/iPadOS Yes Images, PDFs and video reliable; sheet is modal
Safari on macOS Partial Narrower type support than iOS; validate every payload
Chrome on Windows / ChromeOS Yes Routes to the OS share UI
Chrome on Linux Usually not Typically no share implementation at all
Firefox desktop No Fallback path only
Headless / automation No Stub for tests, as in testing share flows

Step 1 — Detect File-Sharing Support Precisely

Support has three layers, and conflating them produces a button that fails after the tap.

// file-share-support.js
export function canShareFiles(files) {
  if (window.isSecureContext !== true) return false;
  if (typeof navigator.share !== 'function') return false;
  if (typeof navigator.canShare !== 'function') return false;   // older Safari
  if (!files?.length) return false;
  return navigator.canShare({ files });
}

Note the third check. Calling navigator.canShare without guarding its existence throws a TypeError on a browser that supports sharing perfectly well — an own-goal that turns a working text share into a broken button.

Step 2 — Construct Real File Objects

Every source of binary data needs the same final step: a File with a name and a type.

// to-file.js
export async function blobToFile(blob, name, type = blob.type) {
  if (!type) throw new Error(`missing MIME type for ${name}`);
  return new File([blob], name, { type, lastModified: Date.now() });
}

export async function canvasToFile(canvas, name = 'image.png', type = 'image/png', quality = 0.92) {
  const blob = await new Promise((resolve, reject) => {
    canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), type, quality);
  });
  return blobToFile(blob, name, type);
}

export async function urlToFile(url, name) {
  const res = await fetch(url, { cache: 'force-cache' });
  if (!res.ok) throw new Error(`fetch ${url} failed: ${res.status}`);
  const blob = await res.blob();
  return blobToFile(blob, name, blob.type || guessType(name));
}

function guessType(name) {
  const ext = name.split('.').pop().toLowerCase();
  return { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
           webp: 'image/webp', pdf: 'application/pdf' }[ext] || 'application/octet-stream';
}

The guessType fallback matters more than it looks: a fetch from cache or from some CDNs can yield a Blob with an empty type, and a file with no MIME type is the payload most likely to be rejected without explanation.

Step 3 — Validate the Exact Payload You Will Send

Validation happens on the real array, immediately before the share, and the result decides the UI.

// share-files.js
import { canShareFiles } from './file-share-support.js';

export async function shareFiles(files, meta = {}) {
  if (!canShareFiles(files)) {
    return { outcome: 'fallback', reason: 'files-unsupported' };
  }

  // Some targets accept files but drop accompanying text; validate the combination.
  const withMeta = { ...meta, files };
  const payload = navigator.canShare(withMeta) ? withMeta : { files };

  try {
    await navigator.share(payload);
    return { outcome: 'shared', includedMeta: payload !== undefined && 'title' in payload };
  } catch (err) {
    if (err.name === 'AbortError') return { outcome: 'dismissed' };
    if (err.name === 'DataError') return { outcome: 'fallback', reason: 'target-rejected' };
    return { outcome: 'fallback', reason: err.name };
  }
}

Degrading from {title, text, files} to {files} rather than abandoning the share is the pattern worth internalising: partial success beats a dead button, and most targets show a sensible default caption anyway.

Payload Degradation Ladder A ladder of three payload shapes. The richest combines title, text and files. If canShare rejects it, the code drops to files only. If files are unsupported entirely, it drops to a link share or a download, so the user always has a working action. 1 · { title, text, url, files } richest payload — validate the whole combination first canShare true send as-is rejected 2 · { files } drop the metadata — most targets caption the file themselves files supported partial success unsupported 3 · link share or download always available — never leave the control inert guaranteed path clipboard or <a download>

Step 4 — Keep Preparation Out of the Gesture

The rule that saves the most debugging time: nothing expensive may run between the click and navigator.share().

// share-button.js
import { shareFiles } from './share-files.js';
import { canvasToFile } from './to-file.js';

export function wireShareButton(button, canvas) {
  let prepared = null;

  // Prepare eagerly — on render, on idle, or when the canvas changes.
  const prepare = async () => { prepared = [await canvasToFile(canvas, 'chart.png')]; };
  prepare();
  canvas.addEventListener('chart:updated', prepare);

  button.addEventListener('click', async () => {
    if (!prepared) return;                    // not ready — keep the fallback visible
    const result = await shareFiles(prepared, { title: 'Quarterly chart' });
    if (result.outcome === 'fallback') downloadFallback(prepared[0]);
  });
}

function downloadFallback(file) {
  const url = URL.createObjectURL(file);
  const a = Object.assign(document.createElement('a'), { href: url, download: file.name });
  a.click();
  URL.revokeObjectURL(url);
}

URL.revokeObjectURL is not optional housekeeping. Object URLs pin their blob in memory for the lifetime of the document, so a gallery that creates one per share leaks the full size of every image the user looked at.

Step 5 — Recover from a Late Rejection

The share sheet is already open when a target refuses your files, which means recovery has to happen after the user has made a choice and had it fail. Handled badly this reads as a crash; handled well it is barely noticeable.

// recover.js
import { shareFiles } from './share-files.js';

export async function shareWithRecovery(files, meta, canonicalUrl) {
  const first = await shareFiles(files, meta);
  if (first.outcome !== 'fallback' || first.reason !== 'target-rejected') return first;

  // The chosen app refused the bytes. Offer the same content as a link,
  // which no target rejects on size and which every browser can send.
  if (typeof navigator.share === 'function' && canonicalUrl) {
    try {
      await navigator.share({ ...meta, url: canonicalUrl });
      return { outcome: 'shared', degraded: 'link' };
    } catch (err) {
      if (err.name === 'AbortError') return { outcome: 'dismissed' };
    }
  }

  return { outcome: 'download', file: files[0] };
}

Two properties make this work. The retry keeps the user’s original intent — the same content, a different envelope — instead of dumping them back at the start of the flow. And it never loops: exactly one degradation step is attempted, so a stubbornly failing target ends at a download rather than reopening the sheet indefinitely.

It is worth wiring the same recovery into the queue path as well. When a share is captured while the device is offline, the payload is replayed later against a target chosen at that point, so the rejection can surface long after the original interaction — see offline share queue implementation for how the retry state is persisted alongside the payload.

Platform Gotchas

HEIC on iOS. Photos picked from an iPhone camera roll can arrive as image/heic, which many targets and most servers reject. Convert to JPEG or PNG through a canvas before sharing if the destination is unknown.

Empty MIME types. A File built from a cached fetch may carry type: ''. Always pass an explicit type; never rely on the blob’s own.

Desktop Safari is narrower than iOS. The same code path that shares a PDF happily on an iPhone may return false from canShare on macOS. This is why validation must run per payload rather than once at startup.

Multiple files are not universally supported. A payload of one file may validate while the same files as an array of five does not. Test the real count, and consider zipping — though a zip is itself a type some targets refuse.

Large payloads fail at the target, not the API. canShare says nothing about size. Tens of megabytes commonly produce a DataError after the sheet opens, so compress first and share a link to the original.

Before any of the mechanics matter, there is a design decision that determines how much of this you have to get right: should the share carry the file at all?

Sending bytes is the right choice when the content is small, self-contained and meaningful offline — a generated chart, a ticket, a receipt, a photo the user just edited. The recipient gets something they can open immediately, with no account, no login and no expiring URL. This is the case the Web Share API was designed for, and within a few megabytes it is reliable across every platform that supports file sharing at all.

Sending a link is the right choice when the content is large, when it is likely to change, or when access should be revocable. A signed URL to a report is a few hundred bytes regardless of how big the report is; it works on Firefox and Linux Chrome where file sharing does not exist; it never trips a receiving app’s size limit; and it can be revoked after the fact, which raw bytes never can. The trade-off is that the recipient needs connectivity and, often, authorisation.

A useful default is to decide by size and mutability rather than by content type:

// what-to-send.js
const INLINE_LIMIT = 5_000_000;

export function decidePayloadKind({ file, canonicalUrl, mutable = false }) {
  if (!file) return { kind: 'link', url: canonicalUrl };
  if (mutable) return { kind: 'link', url: canonicalUrl, reason: 'content changes' };
  if (file.size > INLINE_LIMIT) return { kind: 'link', url: canonicalUrl, reason: 'too large' };
  return { kind: 'file', file };
}

Making this call explicitly, once, at the top of the flow removes a surprising amount of downstream complexity — every size workaround, every DataError retry and every unsupported-platform branch below exists to handle the file case, and the link case needs none of them.

Three Gatekeepers, Three Verdicts A file share must pass the browser, which checks types and shape; the operating system, which routes it to a chosen app; and the receiving application, which applies its own limits. Only the first is visible to your code before the sheet opens. browser checks types and shape answers via canShare() synchronous, free the only one you can ask operating system lists apps that accept the declared types no API surface at all runs after your code finishes receiving app enforces size and format rejects as DataError after the sheet is open why the catch branch exists

Testing and Verification

Verify file sharing on hardware; simulators have too few targets to be conclusive. On a phone, share to at least three destinations — a messaging app, mail, and a cloud drive — because each applies different rules. In DevTools, confirm that navigator.canShare({ files }) returns true for your real payload before any UI is drawn, and confirm the download fallback runs when you force canShare to return false. Unit tests should cover the Blob-instead-of-File mistake explicitly, since it is the one that survives code review most often.

FAQ

Why does navigator.share reject a Blob?

The specification requires the files array to contain File objects, and a File is a Blob with a name and a last-modified time. Passing a bare Blob produces a TypeError because there is no filename for the receiving app to use. Wrap it: new File([blob], 'photo.png', { type: blob.type }).

Can I share files and a URL in the same call?

You can include title, text, url and files together, but support for the combination is not universal — some targets accept files and drop the text, and some browsers reject the mixed payload outright. Validate the exact combination with navigator.canShare before calling share, and prepare a files-only or link-only variant as a fallback.

Is there a maximum file size for the Web Share API?

The specification sets no limit, but implementations and receiving apps do, and neither publishes a number. Payloads under a few megabytes are reliable; tens of megabytes frequently fail at the target rather than at the API. Keep media compressed and offer a link to the full-size asset instead of shipping the bytes.

Why does canShare return true but share() still fail?

canShare only reports whether the browser believes the payload is shareable in principle. The chosen target applies its own rules, so a messaging app that accepts images may still refuse a 40 MB video, surfacing as a DataError after the sheet is already open. Always keep a catch branch for it.