Save a Generated PDF on Mobile and Desktop
Use a mobile user-agent check to branch between two PDF delivery strategies — opening the PDF in a new tab on mobile (where programmatic downloads are blocked) and triggering a file download on desktop — ensuring a consistent save experience across all devices.
When generating a PDF client-side in a Fulcrum Report Builder template (using pdf-lib or a similar library), the standard <a download> click trick works on desktop browsers but silently fails on most mobile browsers, which block programmatic downloads. The fix is to detect the device type and open the PDF in a new browser tab on mobile instead.
Code
This snippet assumes pdfDoc is a pdf-lib PDFDocument instance that has already been built. Replace pdfDoc.save() / pdfDoc.saveAsBase64() with the equivalent method from your PDF library if you're using something else.
async function savePdf(pdfDoc, filename) {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile) {
// Mobile browsers block programmatic <a download> clicks.
// Instead, open the PDF as a blob URL in a new tab so the user
// can use the browser's native "Save" / "Share" options.
const pdfBytes = await pdfDoc.save(); // Returns Uint8Array
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const blobUrl = URL.createObjectURL(blob);
const newTab = window.open(blobUrl, '_blank');
if (!newTab) {
alert('Pop-up blocked. Please allow pop-ups for this site to save the PDF.');
}
} else {
// Desktop: trigger a standard file download via a temporary <a> element.
const pdfDataUri = await pdfDoc.saveAsBase64({ dataUri: true });
const link = document.createElement('a');
link.href = pdfDataUri;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}Usage in a Report Template
Call savePdf() after the PDF has been fully assembled, typically in a button's click handler or at the end of an async generation function. The filename argument can include EJS expressions resolved before the template is served:
// EJS resolves record.displayValue before the browser receives the page.
// The resulting filename might be: "Inspection 2025-03-24.pdf"
const filename = '<%= record.displayValue.replaceAll("\r\n", " ") %>' + '.pdf';
document.getElementById('save-btn').addEventListener('click', async () => {
document.getElementById('processing').style.display = 'block';
document.getElementById('save-btn').disabled = true;
// ... build pdfDoc here ...
await savePdf(pdfDoc, filename);
document.getElementById('processing').style.display = 'none';
document.getElementById('finish').style.display = 'block';
});Why This Is Needed
iOS Safari and most Android browsers treat <a download> as a navigation event rather than a download trigger. Calling link.click() programmatically has no effect, or navigates away from the report page. Opening a blob: URL in a new tab works because the browser's built-in PDF viewer provides its own save/share UI.
Notes
Revoke the blob URL after the tab opens to avoid memory leaks in long-lived sessions: setTimeout(() => URL.revokeObjectURL(blobUrl), 10000).
Pop-up blockers may prevent window.open() on mobile if the call isn't triggered directly by a user gesture (e.g., it's inside a setTimeout or a long async chain). Keep the window.open() call as close to the user event handler as possible.
saveAsBase64({ dataUri: true }) returns a string like data:application/pdf;base64,.... The base64 data URI approach is convenient on desktop but creates a very large string for big PDFs. For large PDFs, prefer save() + Blob + URL.createObjectURL() on desktop as well.
Updated 7 months ago