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.
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 };
}
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.