How to Serve PDFs as application/octet-stream with PDF.js
Serve PDFs as application/octet-stream without breaking PDF.js: headers, range requests, CORS, runnable code, troubleshooting, and performance guidance.
Yes, PDF.js can load a PDF served as application/octet-stream. Fetch the bytes explicitly, pass them to pdfjsLib.getDocument, and do not rely on the browser’s native PDF handler. For the clearest browser behavior, serve known PDFs as application/pdf with Content-Disposition: inline. The generic application/octet-stream type commonly makes direct navigation or a normal link download the file.
1. The correct mental model
Two independent things must work:
- Payload: the response body must contain valid PDF bytes, normally beginning with
%PDF-. - HTTP delivery: headers, range responses, streaming, and CORS must allow the viewer to retrieve those bytes.
Content-Type describes the media type of the representation. application/octet-stream is a generic binary type, so browsers commonly treat it as a download. Content-Disposition: inline permits display; attachment requests download. See MDN’s Content-Type reference, Content-Disposition, and MIME types.
2. Recommended response headers
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: inline; filename="document.pdf"
Content-Length: 123456
Accept-Ranges: bytes
If an upstream system requires application/octet-stream, keep the PDF body unchanged and let PDF.js fetch it as data. You can still send Content-Disposition: inline, but direct browser navigation may remain download-like because the media type is generic.
| Header | Purpose | Typical value |
|---|---|---|
Content-Type |
Identifies the representation | application/pdf or required application/octet-stream |
Content-Disposition |
Display versus download hint | inline; filename="document.pdf" |
Content-Length |
Exact byte length | Decimal byte count |
Accept-Ranges |
Advertises partial byte support | bytes |
3. Load an octet-stream PDF in PDF.js
PDF.js accepts a URL, but an explicit fetch gives your application control over the response and avoids the browser’s download decision. The following page works with either application/pdf or application/octet-stream.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>PDF.js octet-stream viewer</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.mjs" type="module"></script>
</head>
<body>
<canvas id="page"></canvas>
<script type="module">
import * as pdfjsLib from "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.mjs";
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.worker.min.mjs";
async function renderFirstPage(url) {
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) throw new Error(`PDF request failed: ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
const header = new TextDecoder().decode(bytes.subarray(0, 5));
if (header !== "%PDF-") throw new Error("Response is not a PDF");
const loadingTask = pdfjsLib.getDocument({ data: bytes });
const pdf = await loadingTask.promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = document.querySelector("#page");
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
}
renderFirstPage("/files/document.bin").catch(console.error);
</script>
</body>
</html>
For a normal URL load, PDF.js can manage streaming and ranges itself:
const loadingTask = pdfjsLib.getDocument({
url: "/files/document.pdf"
});
const pdf = await loadingTask.promise;
The PDF.js API defaults are disableRange: false, disableStream: false, disableAutoFetch: false, and rangeChunkSize: 65536 bytes. These defaults preserve progressive loading when the server supports it. See the PDF.js API reference.
4. Implement byte-range responses
When PDF.js sends Range: bytes=start-end, return 206 Partial Content with metadata describing exactly the bytes sent.
HTTP/1.1 206 Partial Content
Content-Type: application/pdf
Content-Range: bytes 0-65535/123456
Content-Length: 65536
Accept-Ranges: bytes
For an unsatisfiable range, return 416 Range Not Satisfiable. A server that ignores Range and returns the complete file with 200 can still work, but PDF.js loses progressive partial fetching.
Runnable Node.js range server
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
const filePath = path.resolve("./document.pdf");
const server = http.createServer((req, res) => {
if (req.url !== "/document.bin") {
res.writeHead(404); return res.end("Not found");
}
const stat = fs.statSync(filePath);
const total = stat.size;
const range = req.headers.range;
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", 'inline; filename="document.pdf"');
if (!range) {
res.writeHead(200, { "Content-Length": total });
return fs.createReadStream(filePath).pipe(res);
}
const match = /^bytes=(\\d*)-(\\d*)$/.exec(range);
if (!match) { res.writeHead(416, { "Content-Range": `bytes */${total}` }); return res.end(); }
const start = match[1] ? Number(match[1]) : Math.max(0, total - Number(match[2]));
const end = match[2] ? Number(match[2]) : total - 1;
if (start > end || start < 0 || end >= total) {
res.writeHead(416, { "Content-Range": `bytes */${total}` }); return res.end();
}
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${total}`,
"Content-Length": end - start + 1
});
fs.createReadStream(filePath, { start, end }).pipe(res);
});
server.listen(8080, () => console.log("http://localhost:8080/document.bin"));
5. Cross-origin PDFs and CORS
PDF.js does not allow cross-origin loading by default. Either proxy the PDF through your viewer’s origin or configure CORS on the PDF endpoint. A controlled viewer origin can receive:
Access-Control-Allow-Origin: https://viewer.example
Access-Control-Expose-Headers: Accept-Ranges, Content-Range, Content-Length
Vary: Origin
Expose range headers so browser JavaScript can observe them. Use an explicit origin when credentials are involved; do not combine a wildcard origin with credentialed requests. Mozilla’s PDF.js FAQ and test server document this cross-origin and range behavior.
6. cURL and browser diagnostics
curl -i https://files.example/document.bin
curl -i -H "Range: bytes=0-65535" https://files.example/document.bin
curl -I -H "Origin: https://viewer.example" https://files.example/document.bin
Confirm the first response has valid length metadata, the range response is 206, and the body begins with %PDF-. A PDF can contain binary bytes after that marker, so do not decode the whole file as text.
7. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Browser downloads instead of displaying | application/octet-stream or Content-Disposition: attachment |
Load bytes through PDF.js; for direct display use application/pdf and inline. |
| PDF.js reports an invalid PDF | HTML error page, JSON, compressed corruption, or truncated body | Inspect status and first five bytes; verify Content-Length and proxy behavior. |
| Only the first page or random pages fail | Incorrect Content-Range or range length |
Return exact inclusive start/end values and matching Content-Length. |
| CORS error | Missing or mismatched Access-Control-Allow-Origin |
Use a same-origin proxy or allow the exact viewer origin and expose range headers. |
| Many requests consume too much bandwidth | Server ignores ranges or PDF.js auto-fetches | Implement 206; if ranges are impossible set disableRange: true. |
| Credentials are not sent | Fetch mode or server CORS policy omits credentials | Use credentials: "include" when required and configure explicit origin plus credential support. |
8. Performance, reliability, and cost choices
- Ranges: support
206for large documents and deep links to later pages. - Streaming: retain PDF.js streaming defaults unless your server or proxy cannot stream.
- Auto-fetch: set
disableAutoFetch: truetogether with disabled streaming when speculative downloads must stop. - Fallback: set
disableRange: trueonly when the endpoint cannot implement ranges; the browser then fetches the full file. - Integrity: avoid gzip or transformations that make byte positions disagree with
Content-RangeandContent-Length. - Caching: cache immutable PDFs by URL or content hash, and ensure authorization does not leak one user’s document to another.
9. Or skip the browser setup
ScreenshotNeo can capture a URL as a clean screenshot or PDF through one GET request. Its service accepts consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the page verdict and billing status. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API documentation for PDF options and the complete parameter list.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Every feature is available on every plan. The free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
10. FAQ
Can I use getDocument({url}) with octet-stream?
Usually yes if the endpoint supports CORS and returns valid PDF bytes. Fetching the bytes yourself gives clearer error handling and avoids native download behavior.
Must every PDF endpoint support ranges?
No. PDF.js can load a complete 200 response, but range support reduces bandwidth and improves progressive viewing for large files.
Should I change octet-stream to application/pdf?
Do so when you control the endpoint and the resource is always a PDF. Keep octet-stream only when an upstream contract requires it, and load through PDF.js explicitly.
Why expose Content-Length and Content-Range?
They let PDF.js and the browser understand document size and which byte interval was returned, especially for progressive loading.


