Sharing PDF Files from the Browser

This guide is part of Sharing Files and Media Payloads, which itself is part of Native Device Integration Patterns.

Invoices, tickets, reports, boarding passes — PDFs are the payload users most want to push straight into Mail, Messages or a cloud drive without downloading first. The Web Share API can do it, but PDFs expose two problems that image sharing does not: the MIME type is frequently lost on the way from the server to the File constructor, and documents are large enough that the receiving app, not the browser, becomes the limiting factor.

Feature Detection Gate

Validate with a real application/pdf file, because a browser can accept images and refuse documents.

// pdf-share-support.js
export function canSharePdf(file) {
  if (window.isSecureContext !== true) return false;
  if (typeof navigator.share !== 'function') return false;
  if (typeof navigator.canShare !== 'function') return false;
  if (!(file instanceof File) || file.type !== 'application/pdf') return false;

  try {
    return navigator.canShare({ files: [file] });
  } catch {
    return false;
  }
}

The explicit file.type assertion catches the most common defect at the boundary: a File whose type is '' or application/octet-stream may still pass canShare on a permissive browser and then arrive at the target as an unrecognised blob.

Step 1 — Get the Bytes and Force the Type

Whether the PDF comes from a server or a client-side generator, the final step is identical — and it must not trust the incoming type.

// pdf-file.js
const PDF_TYPE = 'application/pdf';

export async function fetchPdfFile(url, name = 'document.pdf') {
  const res = await fetch(url, { credentials: 'same-origin' });
  if (!res.ok) throw new Error(`PDF fetch failed: ${res.status}`);

  const blob = await res.blob();
  // Never reuse blob.type — a cached or misconfigured response may report '' here.
  return new File([blob], ensurePdfName(name), { type: PDF_TYPE, lastModified: Date.now() });
}

export function bytesToPdfFile(bytes, name = 'document.pdf') {
  return new File([bytes], ensurePdfName(name), { type: PDF_TYPE, lastModified: Date.now() });
}

function ensurePdfName(name) {
  return name.toLowerCase().endsWith('.pdf') ? name : `${name}.pdf`;
}

ensurePdfName looks like belt-and-braces and is not. Several receiving apps dispatch on the file extension rather than the declared MIME type, so invoice-4471 arrives as an unopenable attachment while invoice-4471.pdf opens in the system reader.

Building a Correct PDF File Object A server response and a client-side generator both produce raw bytes whose declared type is unreliable. A normalisation step forces application/pdf and appends a .pdf extension, producing a File that receiving apps can dispatch on by type or by name. fetch(url) → blob() blob.type may be '' client generator Uint8Array of bytes normalise type: 'application/pdf' name ends with .pdf File ready to share · dispatchable by MIME type · dispatchable by extension · opens in the system reader Skipping normalisation is the top cause of "shared as an unknown file" reports

Step 2 — Prepare Early, Share Inside the Gesture

A PDF fetch is a network round trip, so it absolutely cannot live inside the click handler. Fetch when the document becomes relevant — when the invoice view opens, say — and hold the File.

// invoice-share.js
import { fetchPdfFile } from './pdf-file.js';
import { canSharePdf } from './pdf-share-support.js';

export function createInvoiceSharer(invoiceId) {
  let file = null;
  let error = null;

  const prepare = async () => {
    try {
      file = await fetchPdfFile(`/invoices/${invoiceId}.pdf`, `invoice-${invoiceId}.pdf`);
    } catch (err) {
      error = err;
    }
  };

  async function share() {
    if (!file) return { outcome: 'fallback', reason: error ? 'fetch-failed' : 'not-ready' };
    if (!canSharePdf(file)) return { outcome: 'fallback', reason: 'unsupported' };

    const payload = { files: [file], title: `Invoice ${invoiceId}` };
    const finalPayload = navigator.canShare(payload) ? payload : { files: [file] };

    try {
      await navigator.share(finalPayload);
      return { outcome: 'shared' };
    } 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 };
    }
  }

  return { prepare, share, isReady: () => file !== null };
}

The DataError branch is not hypothetical for PDFs. A messaging app that happily accepts a 200 KB ticket will refuse a 40 MB scanned report after the sheet has opened, which is the moment a download fallback earns its place.

Step 3 — Always Offer the Download Path

For PDFs the fallback is not a consolation prize; on desktop it is the main route, because Firefox has no share implementation and desktop Safari’s document support is narrow.

// pdf-fallback.js
export function downloadPdf(file) {
  const url = URL.createObjectURL(file);
  const anchor = Object.assign(document.createElement('a'), {
    href: url,
    download: file.name,
    rel: 'noopener'
  });
  document.body.append(anchor);
  anchor.click();
  anchor.remove();
  // Revoke on the next turn so the navigation has certainly started.
  setTimeout(() => URL.revokeObjectURL(url), 0);
}

Revoking on the next tick rather than immediately avoids a race some browsers lose, where the object URL is torn down before the download begins and the user gets a silent no-op.

Step 4 — Keep the Document Small Enough to Travel

Size is the property most likely to break a PDF share, and it is entirely under your control at generation time.

Server-rendered documents should ship without embedded full-resolution scans; downsampling page images to around 150 DPI typically cuts a report by an order of magnitude with no visible loss on a phone. Subsetting fonts rather than embedding whole families removes hundreds of kilobytes per typeface. For anything that remains large, share a link instead of the bytes — a signed URL to the document is smaller, faster, and works on every browser including those with no share support at all.

// choose-strategy.js
const INLINE_LIMIT = 5_000_000;   // 5 MB — comfortably inside what targets accept

export function choosePdfStrategy(file, url) {
  if (!file) return { mode: 'link', url };
  if (file.size > INLINE_LIMIT) return { mode: 'link', url, reason: 'too-large' };
  return { mode: 'file', file };
}
Size Decides File or Link A size axis from zero to fifty megabytes. Below five megabytes the file itself is shared and targets accept it reliably. Above that threshold the flow switches to sharing a signed link, which works on every browser and never hits a target limit. under ~5 MB share the File — reliable 5 – 20 MB target-dependent over ~20 MB expect DataError — share a link 0 5 MB 20 MB 50 MB Reduce first: 150 DPI page images and subsetted fonts usually move a report a whole band left Apps Dispatch on Type or on Extension Some receiving applications choose a handler from the declared MIME type and others from the filename extension, so a PDF must carry both correctly. Getting one right and the other wrong produces an attachment that opens on one device and not another. type application/pdf + name ending .pdf opens in the system reader on every platform tested correct type, no extension works where dispatch is by MIME; arrives unopenable where it is by name empty type from a cached fetch the receiving app has nothing to dispatch on at all

Failure Modes and Recovery

Symptom Cause Minimal fix
Arrives as an unknown file File built without application/pdf Force the type in the constructor
Attachment will not open Filename has no .pdf extension Append the extension before sharing
NotAllowedError on tap PDF fetched inside the click handler Fetch on view; keep the handler synchronous
DataError after the sheet opens Target refused a large document Route to the link strategy above the size limit
Download does nothing Object URL revoked too early Revoke on the next tick after click()
Works on iPhone, fails on macOS Desktop Safari accepts fewer types Validate the real file and fall back to download

Browser and Platform Caveat

iOS and iPadOS handle PDF payloads well — Mail, Messages, Files and Books all accept them — and Chrome on Android behaves similarly. Chrome on Windows and ChromeOS route to the OS share UI and generally accept documents. Desktop Safari is the outlier: its accepted-type list is narrower than its mobile counterpart, so the same code that shares a PDF from an iPhone may return false from canShare on the Mac. Firefox has no share implementation on any platform, so plan for the download path to serve a substantial slice of desktop traffic. One further platform note: on iOS the share sheet is modal and suspends page timers, so any progress indicator started before the call will appear frozen until the sheet closes — start it after the promise settles instead.

FAQ

Why does my PDF share as an unknown file type?

The File was created without an explicit type, or with the empty type a cached fetch response can return, so the receiving app has nothing to dispatch on. Always pass { type: 'application/pdf' } to the File constructor rather than reusing blob.type.

Can I share a PDF that only exists on the server?

You must fetch it into the page first, because navigator.share sends File objects rather than URLs. Fetch the bytes before the user gesture, keep the resulting File ready, and share a link instead when the document is too large to hold in memory comfortably.

Is sharing a PDF supported on desktop?

Support is uneven. Chrome on Windows and ChromeOS generally accepts it, desktop Safari is narrower than iOS, and Firefox has no share implementation at all. Validate the exact File with canShare and route the remaining users to a download.