File Downloads in a WebView App: PDFs, Invoices & Images

File Downloads in a WebView App: PDFs, Invoices & Images

A customer taps "Download Invoice" in your app. On the website, that link just works. Inside the wrapped app, nothing happens — no file, no error, no explanation. This is one of the most predictable WebView gaps, and it catches almost every team building their first wrapped app, because a WebView has no built-in download handling at all. It's not broken. It was never implemented.

Both Android and iOS require you to explicitly wire up download handling. Neither platform does it automatically, and the two platforms need genuinely different code.

The Short Answer

A plain WebView does not download files by default on either platform — you have to implement it explicitly, and Android and iOS each need their own approach. Here's the shape of the fix on both.

PlatformWhat to implementHandles
AndroidDownloadListener + DownloadManagerDirect file URLs (PDFs, images, exports)
iOS 14.5+WKDownloadDelegateNavigation-triggered downloads
iOS before 14.5Manual URLSession fetchSame, no native API available
Both platformsJavaScript bridge + base64Client-side generated files (blob: URLs)

None of this is unusual or a sign your integration is broken — it's simply work that has to be done once, at the app-shell level, and then every downloadable file on your site works from then on.

Why Downloads Don't Work by Default

A WebView's job is to render web content, and a downloadable file isn't web content — it's a resource the browser is supposed to hand off to something else. A normal desktop or mobile browser has an entire download manager built in: a UI, a storage location, permission handling, progress tracking. A bare WebView component has none of that. It's designed to display pages, not manage a device's file system.

When a link resolves to a PDF, an image meant to be saved, or any file with a Content-Disposition: attachment header, a browser intercepts that and offers to save it. A WebView, with no extra code, either tries and fails to render the file inline, shows a blank screen, or does nothing visible at all — and none of it surfaces a helpful error to the user. This is the same category of gap covered in our guide to what a WebView app actually is: the technology is a real browser engine, but several browser conveniences have to be added back in deliberately.

It helps to think of it this way: a full browser bundles three separate jobs — rendering pages, managing a download queue, and providing a file system UI for the user to find what they saved. A WebView component only signs up for the first job. The other two are the host app's responsibility, and if nobody writes that code, the download simply never happens. This is a deliberate design choice on Google's and Apple's part, not an oversight — a bare rendering engine embedded inside someone else's app shouldn't silently start writing files to a user's device without the host app's explicit involvement. Our Android Studio WebView build guide covers several other gaps in this same category, if downloads turn out not to be the only thing missing from your first build.

Android: DownloadListener + DownloadManager

On Android, you attach a DownloadListener to your WebView, and when it fires, you hand the request off to Android's built-in DownloadManager. This is the standard, Google-documented pattern, and it covers the large majority of download cases.

The listener receives five pieces of information whenever a navigation resolves to a downloadable file: the URL, the user agent, the content disposition header, the MIME type, and the content length. Your code passes the URL and metadata to DownloadManager, which performs the download in the background, shows the standard Android download notification, and saves the file to the public Downloads directory.

On storage permissions: if you're targeting Android 10 (API 29) or later and saving into the public Downloads collection through DownloadManager, you generally don't need to request WRITE_EXTERNAL_STORAGE — that specific path is exempt from scoped storage restrictions. You only need broader storage permissions if you're writing files somewhere outside the standard Downloads directory.

This pattern handles direct file links reliably. It does not, on its own, handle files your web page generates dynamically in JavaScript — that's a separate problem, covered in the blob URL section below.

iOS: WKDownloadDelegate (and the Pre-14.5 Workaround)

On iOS 14.5 and later, WKWebView has a native download API — WKDownloadDelegate — that lets you handle navigation-triggered downloads without leaving the WebKit framework. Before iOS 14.5, no such API existed, and developers had to intercept the navigation response manually and fetch the file with URLSession instead.

With WKDownloadDelegate implemented, when a navigation resolves to a response that WebKit determines should be downloaded rather than rendered, your delegate is asked where to save it, and WebKit handles the actual transfer. This is a meaningfully cleaner API than the pre-14.5 workaround, and since 14.5 has been out for years at this point, there's rarely a reason to still support the manual fallback unless you have a specific legacy deployment target.

Worth noting: WKWebView shares its rendering engine with Safari, and that has one pleasant side effect covered in the PDF section below — inline PDF viewing often just works on iOS without any extra code at all, which is not the case on Android. This same shared-engine relationship is also why certain navigation behaviors, like the ones covered in our deep links in WebView apps guide, tend to be more forgiving on iOS than on Android by default.

The Blob URL Problem: Client-Side Generated Files

Neither DownloadListener on Android nor WKDownloadDelegate on iOS reliably catches files your JavaScript generates on the fly as a Blob — a dynamically built invoice PDF, an exported CSV, a generated image. This is a distinct problem from the direct-file-URL case, and it needs its own fix.

The reason is architectural: a blob: URL only has meaning inside the JavaScript context that created it. It isn't a real network resource the native download APIs can fetch independently — it's an in-memory object the page's own script has to hand over explicitly. Neither platform's native download hooks were designed to reach into a page's JavaScript memory to grab that data.

The fix is a JavaScript bridge. Your web page's JavaScript reads the Blob using FileReader, converts it to a base64 string, and passes that string to native code through a bridge — addJavascriptInterface on Android, WKScriptMessageHandler on iOS. The native side decodes the base64 and writes the file to disk using the platform's normal file-saving APIs. It's a few extra lines on both the web and native side, but it's the only reliable way to catch this category of download.

If your site uses a client-side PDF or export library — jsPDF, xlsx.js, and similar tools all typically produce Blobs — assume you'll need this bridge. Test the exact export flow your site uses before considering downloads "done."

PDFs Specifically: Why iOS and Android Behave Differently

iOS tends to handle PDFs more gracefully out of the box than Android does, and the reason is architectural, not accidental. WKWebView is built on the same WebKit engine as Safari, and Safari has had inline PDF viewing for years. Load a PDF URL in a WKWebView with no extra code, and it often just displays — no download listener required.

Android's WebView doesn't carry the same built-in PDF viewer. Without a DownloadListener implemented and a way to hand the file to something that can display it, tapping a PDF link on Android can fail silently where the identical link works fine on iOS in the same app. This platform asymmetry is one of the more common "it works on my iPhone but not my Android device" bug reports a team gets in the first week of testing.

Two practical fixes for Android, depending on what you need:

  • If you want the file downloaded and saved: use the DownloadListener + DownloadManager pattern above, and open the downloaded file with an Intent pointing at whatever PDF viewer the device has installed.
  • If you just want the PDF viewable inline, not necessarily saved: route the URL through a web-based viewer like Mozilla's PDF.js or Google's Docs Viewer, which renders the PDF inside the WebView through their own viewer page. This sidesteps native download code entirely, at the cost of loading through a third-party viewer.

Step-by-Step: Implementing Reliable Downloads

1

Confirm your server sends Content-Disposition headers

Every downloadable file should be served with Content-Disposition: attachment; filename="yourfile.pdf" so the platform knows to treat it as a download, not a page to render.

2

Implement setDownloadListener on Android

Attach the listener to your WebView, and hand off the URL and metadata it receives to DownloadManager when it fires.

3

Handle scoped storage correctly

On Android 10+, saving into the public Downloads collection through DownloadManager doesn't require WRITE_EXTERNAL_STORAGE. Confirm your target SDK and adjust if you're writing anywhere else.

4

Implement WKDownloadDelegate on iOS 14.5+

For iOS 14.5 and later, this handles navigation-triggered downloads natively. For earlier versions, intercept the navigation response and fetch manually with URLSession.

5

Add a JavaScript bridge for blob: URL downloads

Read the Blob with FileReader, convert to base64, and pass it to native code to write the file — this is the only reliable way to catch client-side generated files.

6

Test every file type on real devices

Test PDFs, images, and any blob-generated exports on a real Android device and a real iOS device — simulators and emulators don't reliably reproduce download and storage permission behavior.

Common Mistakes

Testing only on one platform

Because iOS often handles PDFs gracefully with zero extra code, teams frequently ship after testing only on iPhone, then discover Android downloads are completely broken once real users report it.

Assuming DownloadListener catches everything

It catches direct file URLs. It does not reliably catch blob: URLs from client-side PDF or export libraries — that needs the separate JavaScript bridge covered above.

Requesting broad storage permissions unnecessarily

If you're only saving to the public Downloads directory via DownloadManager on Android 10+, requesting WRITE_EXTERNAL_STORAGE is usually unnecessary and adds a permission prompt users have no reason to see.

Forgetting the redirect case

Some download endpoints redirect once or twice before landing on the actual file — an authenticated download link that redirects to a signed, expiring URL on cloud storage is a common pattern. Make sure your DownloadListener and WKDownloadDelegate logic follows redirects rather than assuming the first URL they see is the final one; a naive implementation that grabs the first URL can end up downloading an authentication page instead of the actual file.

Frequently Asked Questions

Why doesn't a PDF link work when I tap it in my WebView app?

Because a plain WebView, on both Android and iOS, has no built-in handling for files it can't render as a webpage. Tapping a link to a PDF, an invoice, or any downloadable file either does nothing, shows a blank screen, or fails silently, because the WebView doesn't know to hand that URL off to a download manager or an external viewer. This is expected default behavior, not a bug — it needs to be explicitly implemented.

Does Android WebView download files automatically?

No. You have to attach a DownloadListener to the WebView yourself. When a user taps a link that resolves to a downloadable file, the listener receives the URL, MIME type, and content disposition, and your code hands that off to Android's DownloadManager, which performs the actual download and shows the standard system notification. Without this listener attached, the tap does nothing.

Do I need storage permission to download files in my Android WebView app?

Usually not, if you use DownloadManager correctly. On Android 10 (API 29) and later, saving into the public Downloads collection through DownloadManager is exempt from scoped storage restrictions, so you don't need to request WRITE_EXTERNAL_STORAGE for that specific case. You only need broader storage permissions if you're writing files somewhere outside the standard Downloads directory.

How do I handle file downloads in a WKWebView app on iOS?

On iOS 14.5 and later, implement the WKDownloadDelegate protocol, which lets WKWebView hand off navigation-triggered downloads to native code directly. On earlier iOS versions, there's no built-in download API for WKWebView — you have to intercept the navigation response yourself and fetch the file manually using URLSession, then save it to the device.

Why does my invoice download work on the website but not in the app?

The most common cause is that the invoice is generated client-side as a Blob — common with JavaScript-based PDF generation libraries — rather than served as a direct file URL from your server. A browser handles Blob downloads natively, but a WebView's download listener (Android) or download delegate (iOS) typically doesn't fire for blob: URLs, because they only exist inside the page's JavaScript context. The fix is a JavaScript bridge that reads the Blob, converts it to base64, and passes it to native code to write the file.

Why does a PDF open fine on iOS but not on Android inside the same WebView app?

iOS's WKWebView shares its rendering engine with Safari, which includes built-in inline PDF viewing, so a PDF often just displays correctly without any extra code. Android's WebView doesn't have that same built-in PDF renderer, so without a DownloadListener implemented and a way to hand the file off to an external PDF viewer or a web-based viewer, tapping a PDF link on Android can fail where the identical link works fine on iOS. This platform asymmetry catches a lot of developers off guard the first time they test both platforms.

What's the simplest fix if I just need PDFs to open, not necessarily download?

Route the PDF URL through a web-based viewer instead of trying to render or download it directly — Mozilla's PDF.js or Google's Docs Viewer can both display a PDF inline inside the WebView by loading the file through their viewer URL. This sidesteps the native download implementation entirely if a user only needs to view the file, not save it to their device, though it does mean the PDF loads through a third-party viewer rather than natively.

Key Takeaways

  • No WebView downloads files by default — this is expected on both platforms, not a broken integration.
  • Android needs DownloadListener + DownloadManager, and generally doesn't need WRITE_EXTERNAL_STORAGE for standard Downloads saves on Android 10+.
  • iOS 14.5+ has a native WKDownloadDelegate API — earlier versions need a manual URLSession fetch instead.
  • Blob-generated files (client-side PDFs, CSV exports) need a separate JavaScript bridge — neither native download API catches them reliably.
  • iOS handles inline PDFs more gracefully than Android by default — a real platform asymmetry, not an inconsistency in your code.
  • Test both platforms with real files before considering downloads finished — this is the single most common gap that ships untested.

File downloads are one of a handful of technical details that separate a genuinely production-ready WebView app from a thin wrapper that breaks the moment a user does something beyond browsing — alongside Google sign-in, payment gateways, and staying logged in across restarts. If your site works entirely offline-first, our guide on whether a WebView app can work offline covers that related gap too.

Invoices, Receipts, Exports — Get Them Working the First Time

AppOfWeb builds your website into a native Android and iOS app for a one-time fee — including the download handling that breaks thin wrappers.

Get Your Free Demo →