ScreenshotNeo

BlogHTML to image & PDF

How to Read PDF Binary Data and Send It in an HTTP Response

Return PDFs correctly from Flask, Express, or NestJS with binary-safe code, browser headers, streaming guidance, security checks, and troubleshooting.

By the ScreenshotNeo team30 September 20268 min read

How to Read PDF Binary Data and Send It in an HTTP Response

Short answer: read the PDF as bytes or as a stream, return those bytes through your framework’s response API, and set Content-Type: application/pdf. Use Content-Disposition: inline when the browser should display the document, or attachment; filename="report.pdf" when it should download it. Use a trusted server-side path, a binary file-like object, or a stream; never pass an unrestricted path supplied by a request.

This guide shows complete implementations in Flask, Express, and NestJS, then covers memory use, streaming, caching, security, failure handling, and testing. The same HTTP rules apply whether the PDF is generated locally, read from disk, or fetched from another service.

1. The HTTP response a PDF needs

A PDF response has two important parts:

A PDF response carries binary bytes plus headers that control its media type and presentation.
A PDF response carries binary bytes plus headers that control its media type and presentation.
  • Body: the raw PDF bytes. Do not convert them to UTF-8 text or JSON.
  • Headers: metadata that tells the client what the bytes are and how to present them.
Header Typical value Purpose
Content-Type application/pdf Identifies the body as a PDF.
Content-Disposition inline Asks a browser to preview the PDF when it can.
Content-Disposition attachment; filename="report.pdf" Suggests a download with a filename.
Content-Length Byte count Optional for streams; useful when known.
Cache-Control Application-specific Controls intermediary and browser caching.

inline is a presentation hint, not a guarantee. The browser, user settings, and installed PDF viewer decide whether to display or download the file. A filename is most useful with attachment.

2. Choose bytes, a file path, or a stream

In-memory bytes

Use a byte buffer when the PDF is already generated in memory and is small enough for your request limits. Wrap it in a binary file-like object where the framework expects one, and reset the read pointer to position zero.

A trusted filesystem path

A framework’s file-serving helper can use operating-system metadata and efficient transfer behavior. This is usually the simplest option for a PDF stored by your application. The path must come from trusted application state or be constrained to an approved root. Flask explicitly warns against passing user-provided paths to send_file; Express provides a root option for constrained paths. See the Flask send_file API and Express download API.

Streaming

Stream when a PDF is large, generated incrementally, or coming from an upstream service. Streaming avoids collecting the entire document in application memory, but error handling changes after response headers or body bytes have been sent. NestJS documents StreamableFile and adapter-specific behavior in its streaming files guide.

3. Flask: return a PDF from bytes or disk

Return an in-memory PDF

from io import BytesIO
from flask import Flask, send_file

app = Flask(__name__)

@app.get("/reports/<report_id>.pdf")
def report(report_id):
    # Replace this with your trusted PDF generator.
    pdf_bytes = build_report_pdf(report_id)
    stream = BytesIO(pdf_bytes)
    stream.seek(0)
    return send_file(
        stream,
        mimetype="application/pdf",
        as_attachment=False,
        download_name=f"report-{report_id}.pdf",
    )

def build_report_pdf(report_id):
    # A real implementation would call a PDF library or service.
    raise NotImplementedError

send_file accepts a path or a file-like object. File-like objects must be opened in binary mode; with BytesIO, reset the pointer before returning. as_attachment=False produces inline disposition in current Flask versions. Set as_attachment=True to request a download.

Return a trusted file path

from flask import send_file

@app.get("/invoices/<invoice_id>.pdf")
def invoice(invoice_id):
    path = lookup_path_from_database(invoice_id)  # Not the raw query string.
    if path is None:
        return {"error": "not found"}, 404
    return send_file(
        path,
        mimetype="application/pdf",
        as_attachment=True,
        download_name=f"invoice-{invoice_id}.pdf",
        max_age=0,
    )

Do not build path by concatenating a request parameter. Map an identifier to a database record or validate it against an allowlisted directory.

4. Express: send a buffer, file, or stream

Send a PDF buffer

import express from "express";

const app = express();

app.get("/reports/:id.pdf", async (req, res, next) => {
  try {
    const pdf = await buildReportPdf(req.params.id); // Buffer
    res.type("application/pdf");
    res.set("Content-Disposition", `inline; filename="report-${req.params.id}.pdf"`);
    res.send(pdf);
  } catch (error) {
    next(error);
  }
});

app.listen(3000);

Express must receive a Buffer or stream for binary data. Do not call res.json(pdf) or convert the buffer to a string; either changes the representation.

Download a trusted file

import path from "node:path";

app.get("/downloads/:id", (req, res, next) => {
  const fileName = lookupApprovedFileName(req.params.id);
  if (!fileName) return res.sendStatus(404);

  const root = path.resolve("/srv/app/pdf");
  res.download(fileName, "report.pdf", { root }, (err) => {
    if (err && !res.headersSent) next(err);
  });
});

The root constraint prevents a resolved path from escaping the approved directory. If a transfer fails after data has started, the callback may run when it is no longer possible to replace the partial body with a normal JSON error.

Stream from an upstream source

app.get("/reports/:id/stream", async (req, res, next) => {
  try {
    const upstream = await fetchReportStream(req.params.id);
    res.status(200);
    res.set({
      "Content-Type": "application/pdf",
      "Content-Disposition": "inline; filename=\"report.pdf\""
    });
    upstream.body.pipe(res);
  } catch (error) {
    next(error);
  }
});

Check the upstream status before piping, and attach error handlers to both the upstream stream and response. If the upstream fails after piping begins, close the connection or destroy the response according to your stream library’s rules.

5. NestJS: use StreamableFile

import { Controller, Get, Param, StreamableFile } from "@nestjs/common";
import { createReadStream } from "node:fs";
import { join } from "node:path";

@Controller("reports")
export class ReportsController {
  @Get(":id.pdf")
  getReport(@Param("id") id: string): StreamableFile {
    const approvedName = lookupApprovedFileName(id);
    if (!approvedName) {
      throw new Error("Report not found");
    }

    const stream = createReadStream(join("/srv/app/pdf", approvedName));
    return new StreamableFile(stream, {
      type: "application/pdf",
      disposition: `inline; filename="report-${id}.pdf"`,
    });
  }
}

For an attachment, change the disposition to attachment; filename="report.pdf". Supply a length option when you know the size and want an explicit Content-Length. Add framework-level exception handling for errors that occur before the stream starts; once bytes are sent, a conventional error response may be impossible.

6. A framework-neutral implementation checklist

  1. Obtain the PDF from a trusted generator, path, or upstream response.
  2. Keep the body as bytes. Avoid text decoding and JSON serialization.
  3. Set Content-Type: application/pdf.
  4. Choose inline for preview or attachment for download.
  5. Use a safe filename containing only expected characters.
  6. Validate identifiers before resolving filesystem paths.
  7. For large documents, stream and handle errors on every stream.
  8. Return an error status before writing any body bytes when possible.
  9. Test with a PDF viewer and with a raw client that checks the first bytes.

7. Security and correctness edge cases

Path traversal

A request such as ?file=../../secrets.txt must never become a filesystem path. Use an ID-to-record lookup, an allowlist, or a framework root constraint. Resolve the final path and verify it remains inside the approved directory.

Constrain file selection before opening a PDF, then stream it through the response.
Constrain file selection before opening a PDF, then stream it through the response.

Filenames and header injection

Do not copy an arbitrary filename into Content-Disposition. Strip control characters, quotes, and path separators, or generate the name on the server. Keep a fixed ASCII fallback such as report.pdf.

Range requests

PDF viewers sometimes request byte ranges. If your framework’s file helper supports range requests, prefer it for large static files. A hand-written buffer response may not support ranges and can cause a viewer to download the entire file.

Caching and authorization

Private PDFs should not be cached by shared proxies. Consider Cache-Control: private, no-store for sensitive documents. Public, immutable reports can use a long cache lifetime and a content hash in the URL. Apply authorization before opening the file, not after.

Content validation

If a generator or upstream service can return an HTML error page, check its status and, where practical, verify that the payload begins with the PDF signature %PDF-. This is a diagnostic check, not a complete security scanner.

8. Troubleshooting common errors

Symptom Likely cause Fix
Browser shows raw characters Wrong content type or text decoding Return bytes and set application/pdf.
PDF downloads instead of previewing attachment disposition or browser policy Use inline; accept that clients may still download.
Downloaded file is corrupt JSON/string conversion, truncated stream, or proxy modification Use binary APIs, inspect stream errors, and compare byte length.
404 or permission error Incorrect path or authorization order Log the resolved trusted path and check access before opening.
Path traversal finding User input controls a path Use an ID lookup, allowlist, or root constraint.
Headers already sent Stream failed after output began Handle stream errors and destroy the response; do not send a second JSON response.
High memory usage Entire PDFs buffered per request Use a file path or streaming response and enforce size limits.
Viewer waits indefinitely Missing stream termination or upstream stall Propagate end and error events, add upstream and request timeouts.

9. Performance, reliability, and cost considerations

Buffering is straightforward and often fastest for small PDFs, but concurrent large responses multiply memory use. File helpers and streams keep memory closer to a fixed per-request overhead. Measure at your document sizes and concurrency rather than assuming a universal threshold.

Set timeouts for PDF generation and upstream fetches. If generation is expensive, queue it as a job, store the completed file, and let the download endpoint serve the stored artifact. Include an immutable report ID so retries do not create duplicate work. Log status code, byte count, generation duration, and whether the response began before an error.

Do not claim success merely because the HTTP status is 200. A client should check the media type and, when appropriate, the PDF signature. For resumable downloads, use a framework helper or object store that supports range requests.

10. Or skip the browser setup: ScreenshotNeo

If the PDF is a website capture, you can avoid maintaining a headless browser and PDF rendering pipeline with ScreenshotNeo. Its API returns a generated capture from one GET request, and its PDF options cover paper size, margins, landscape mode, and page ranges. See the ScreenshotNeo documentation for request options.

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}`);

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are never billed, and the response identifies the verdict and billing status in X-Page-Verdict and X-Billed. An MCP server lets Claude, Cursor, and other MCP clients take screenshots with take_screenshot, inspect pages with get_page_info, and capture PDFs with capture_pdf. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account to get started.

11. FAQ

Should I base the response on the PDF extension?

No. Set the media type explicitly to application/pdf. The URL extension is optional metadata.

Can I return a PDF inside JSON?

You can base64-encode it, but that increases payload size and makes clients decode it. Return the PDF as the response body when the endpoint’s purpose is file delivery.

Is Content-Length required?

No. It is useful when known, but chunked transfer or framework-managed streaming can work without it.

Why does a PDF viewer sometimes show a blank page?

The body may be truncated, the generator may have produced invalid PDF bytes, or the response may contain an HTML error. Check status, media type, byte count, and the %PDF- signature.

When should I use a stream instead of bytes?

Use a stream for large or incrementally produced documents, especially when buffering would create memory pressure. Use bytes for already-complete, appropriately sized PDFs.