A web application may load a PDF perfectly in a desktop browser but suddenly fail after being compiled as an Android application with Capacitor. One of the most common errors looks like this:
Access to fetch at 'https://example.com/files/document.pdf'
from origin 'http://localhost' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present
on the requested resource.
You may also see:
GET https://example.com/files/document.pdf net::ERR_FAILED 206 (Partial Content)
This can be confusing, especially when the server already contains:
Header set Access-Control-Allow-Origin "*"
The problem is often not simply “CORS is disabled.” PDF viewers, Android WebViews, caching layers and HTTP range requests can interact in ways that make the issue harder to diagnose.
This guide explains the problem at a beginner-friendly level and provides practical solutions for Apache, WordPress, Cloudflare, Capacitor and PDF.js.
Why It Works in the Browser but Not on Android
During local web development, an Ionic or Angular application might run at:
http://localhost:8100
After being compiled with Capacitor and installed on Android, the WebView may instead use an origin such as:
http://localhost
or, depending on the configuration:
capacitor://localhost
The application is therefore requesting a PDF from a completely different domain:
Application:
http://localhost
PDF:
https://example.com/document.pdf
From the browser’s perspective, this is a cross-origin request.
The PDF server must explicitly permit it.
The Important Clue: HTTP 206
One of the most useful clues is:
HTTP/2 206 Partial Content
PDF.js and other PDF viewers do not necessarily download an entire PDF in one request.
Instead, they frequently use HTTP byte-range requests:
Range: bytes=0-1023
The server may respond:
HTTP/2 206 Partial Content
Content-Range: bytes 0-1023/107503
Accept-Ranges: bytes
This allows the viewer to retrieve only the portions of a large PDF that it currently needs.
The problem occurs when the normal 200 OK response contains:
Access-Control-Allow-Origin: *
but the 206 Partial Content response does not.
The desktop test may therefore work while PDF.js running inside Android fails.
A Basic .htaccess Configuration
A common initial configuration looks like this:
<IfModule mod_headers.c>
<FilesMatch "\.(pdf)$">
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, OPTIONS"
</FilesMatch>
</IfModule>
This is a reasonable starting point, but it may not be sufficient when proxies, caches, redirects or partial-content responses are involved.
A more robust version uses Header always:
<IfModule mod_headers.c>
<FilesMatch "\.(pdf)$">
Header always set Access-Control-Allow-Origin "*"
Header always set Access-Control-Allow-Methods "GET, OPTIONS"
Header always set Access-Control-Expose-Headers "Content-Length, Content-Range, Accept-Ranges"
Header always set Accept-Ranges "bytes"
</FilesMatch>
</IfModule>
The important change is:
Header always set
rather than:
Header set
This helps ensure headers are included on responses other than a straightforward 200 OK.
Matching PDFs by Request URI
On some WordPress, Apache or LiteSpeed configurations, matching the request URI can be more reliable than depending exclusively on FilesMatch.
For example:
<IfModule mod_setenvif.c>
SetEnvIfExpr "%{REQUEST_URI} =~ m#\.pdf$#i" IS_PDF=1
</IfModule>
<IfModule mod_headers.c>
Header always set Access-Control-Allow-Origin "*" env=IS_PDF
Header always set Access-Control-Allow-Methods "GET, OPTIONS" env=IS_PDF
Header always set Access-Control-Expose-Headers \
"Content-Length, Content-Range, Accept-Ranges" env=IS_PDF
Header always set Accept-Ranges "bytes" env=IS_PDF
</IfModule>
This flags requests whose URI ends in .pdf and applies the headers accordingly.
For additional security, the expression can be limited to the uploads directory:
SetEnvIfExpr "%{REQUEST_URI} =~ m#^/wp-content/uploads/.*\.pdf$#i" IS_PDF=1
What About CORS Preflight?
Not every CORS request generates a preflight.
A normal GET may be sent directly.
This explains why you might inspect Android network traffic and see:
GET document.pdf
but no:
OPTIONS document.pdf
However, it is still useful to configure OPTIONS correctly because future changes to request headers can cause the browser to perform a preflight.
For example:
<IfModule mod_headers.c>
Header always set Access-Control-Allow-Origin "*" \
"expr=%{REQUEST_METHOD} == 'OPTIONS'"
Header always set Access-Control-Allow-Methods "GET, OPTIONS" \
"expr=%{REQUEST_METHOD} == 'OPTIONS'"
Header always set Access-Control-Allow-Headers "range, content-type" \
"expr=%{REQUEST_METHOD} == 'OPTIONS'"
Header always set Access-Control-Max-Age "86400" \
"expr=%{REQUEST_METHOD} == 'OPTIONS'"
</IfModule>
The important point is that the absence of an OPTIONS request does not mean the error is not CORS-related.
The actual GET response must still contain the appropriate CORS headers.
Testing PDF Range Requests with curl
Testing the ordinary URL is not always enough.
For example:
curl -I https://example.com/document.pdf
might show everything working correctly.
Instead, simulate what PDF.js is doing:
curl -i "https://example.com/document.pdf" \
-H "Origin: http://localhost" \
-H "Range: bytes=0-1023"
A healthy response should resemble:
HTTP/2 206
Content-Type: application/pdf
Content-Length: 1024
Content-Range: bytes 0-1023/107503
Accept-Ranges: bytes
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges
If Access-Control-Allow-Origin is missing from this response, the Android/WebView error has effectively been reproduced outside the application.
This is extremely useful because it separates an Android problem from a server/CDN problem.
When Cloudflare Is Involved
Another important clue is:
server: cloudflare
cf-cache-status: HIT
A request path may actually look like:
Android WebView
|
v
Cloudflare
|
v
Web server / LiteSpeed
|
v
WordPress uploads
If Cloudflare returns a cached PDF response, modifying .htaccess alone may not immediately produce the result you expect.
This is why inspecting the complete HTTP response is important.
Useful headers include:
server: cloudflare
cf-cache-status: HIT
age: 5000
A HIT means Cloudflare returned a cached object.
After changing CORS configuration, purge the affected cached PDF and test again.
Adding CORS Headers at the CDN Layer
Another approach is to configure a CDN response-header transformation.
For public PDFs that do not require credentials, the response can contain:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges
This can be particularly useful because the CDN is the component actually delivering the response to the Android application.
A typical rule condition would target paths ending in:
.pdf
rather than relying on the complete URL.
Conceptually:
IF
request path ends with ".pdf"
THEN
set Access-Control-Allow-Origin = *
set Access-Control-Expose-Headers =
Content-Length, Content-Range, Accept-Ranges
Static vs Dynamic CORS Origins
There are two common approaches.
For completely public PDFs, the simplest configuration is:
Access-Control-Allow-Origin: *
This is appropriate when the request does not depend on authenticated cookies or other credentials.
If credentials are required, * should not be combined with:
Access-Control-Allow-Credentials: true
Instead, the server or CDN should return the permitted requesting origin, for example:
Access-Control-Allow-Origin: http://localhost
Access-Control-Allow-Credentials: true
The application might therefore whitelist origins such as:
http://localhost
http://localhost:8100
capacitor://localhost
Only trusted origins should be reflected.
Caching Can Make Debugging Misleading
Caching is one of the easiest ways to waste time debugging this problem.
Suppose the origin server now returns:
Access-Control-Allow-Origin: *
but the CDN has an older cached 206 response without that header.
The developer changes .htaccess, reloads the Android application and sees exactly the same error.
It appears that the .htaccess modification did nothing.
The real request, however, may never have reached Apache.
When diagnosing this situation, look for:
cf-cache-status: HIT
age: ...
Then purge the cached file and repeat the test.
For applications where PDFs are frequently accessed using byte ranges, another option is to bypass CDN caching for PDFs while diagnosing the problem.
A Useful Debugging Strategy
Instead of repeatedly rebuilding the Android application, isolate each layer.
Start with a normal request:
curl -I https://example.com/document.pdf
Then test the Android origin:
curl -i "https://example.com/document.pdf" \
-H "Origin: http://localhost"
Then reproduce the PDF.js range request:
curl -i "https://example.com/document.pdf" \
-H "Origin: http://localhost" \
-H "Range: bytes=0-1023"
Finally, inspect:
HTTP status
Access-Control-Allow-Origin
Content-Range
Accept-Ranges
cf-cache-status
age
server
If curl reproduces the missing header, there is little reason to keep changing the Angular or Capacitor application. The problem is further upstream.
Common Mistakes
Several mistakes repeatedly appear with this type of issue.
Testing only 200 OK responses. PDF.js may actually be receiving 206 Partial Content.
Assuming every CORS request requires OPTIONS. Many GET requests do not generate preflight requests.
Testing only desktop localhost. http://localhost:8100 and http://localhost are different origins.
Changing .htaccess while testing a CDN cache hit. The request may never reach the modified web server.
Using Access-Control-Allow-Origin: * together with credentials. Authenticated CORS requires explicit origin handling.
Looking only at application code. Failed to fetch from PDF.js is often the symptom rather than the root cause.
Recommended Architecture
For public PDF files, a simple architecture is usually best:
Angular / Ionic / Capacitor
|
| GET + Range
v
CDN
|
v
Apache/LiteSpeed
|
v
PDF file
Both the CDN and origin configuration should be understood, but avoid creating several competing CORS configurations unless necessary.
For public documents, returning:
Access-Control-Allow-Origin: *
on both normal and partial-content responses is generally sufficient.
The most important requirement is that the header reaches the client on the actual response PDF.js receives.
Conclusion
A PDF that works in a desktop Angular application but fails inside an Android Capacitor application is not necessarily an Android or PDF.js bug.
The decisive clue is often:
net::ERR_FAILED 206 (Partial Content)
PDF.js uses byte-range requests, which produce 206 Partial Content responses. If those responses are missing Access-Control-Allow-Origin, the WebView correctly blocks them.
The most effective debugging technique is therefore to reproduce the exact request:
curl -i "https://example.com/document.pdf" \
-H "Origin: http://localhost" \
-H "Range: bytes=0-1023"
If the resulting 206 response does not contain the CORS header, investigate the web server, LiteSpeed configuration, CDN response rules and caching before changing the mobile application.
Once the 206 response contains the correct CORS headers, PDF.js can fetch the document normally from the Capacitor WebView.


