Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Read a PDF as bytes (or as a stream) and return those bytes as the response body. Set Content-Type: application/pdf. Use Content-Disposition: inline when the browser should try to display the document, or attachment; filename="report.pdf" when it should download it. The implementation depends on your framework and on whether the PDF is an in-memory buffer, a trusted file, or a stream.

Choose the response strategy first

There are three practical ways to send a PDF:

Source Best response approach Memory and safety considerations
Bytes already in memory Wrap the bytes in a binary file-like object or framework buffer response. Convenient for appropriately sized documents; the whole PDF is already in application memory.
Trusted server-side file Use the framework’s file-serving API. Often lets the framework manage metadata and transfer behavior. Never turn an unrestricted request parameter into a path.
Large or incrementally generated PDF Use a stream-capable response. Can avoid buffering the entire document, but failures after transmission starts may produce only a partial response.

Before sending anything, make sure the PDF was generated or retrieved successfully. If generation fails before headers are sent, return a normal error response. Once response headers or body bytes have reached the client, many servers cannot replace the partial PDF with a JSON error; adapter behavior differs, so handle stream errors explicitly.

Set the headers that control browser behavior

Content type

Set Content-Type to application/pdf. This tells the client what the response body represents. Framework helpers may call the setting mimetype, contentType, or an equivalent option.

Inline preview or download

Content-Disposition: inline expresses that the browser may display the PDF in its built-in viewer. Content-Disposition: attachment; filename="report.pdf" expresses that it should offer a download with the suggested filename. The final behavior can still depend on browser settings and extensions.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Filename hygiene

Use a fixed or validated filename. Do not copy arbitrary path text or an unsanitized user value into a filesystem path or response header. If a user chooses a document, map an identifier to a server-side record or constrain file resolution to a known root directory.

Flask: return bytes, a file, or a stream

In-memory PDF bytes

Flask’s send_file accepts a filesystem path or a file-like object. For in-memory data, use a binary-mode object and rewind it to position zero before returning it.

from io import BytesIO
from flask import Flask, send_file

app = Flask(__name__)

@app.get("/report.pdf")
def report():
    pdf_bytes = build_pdf_bytes()  # Return valid PDF bytes from your generator.
    pdf_file = BytesIO(pdf_bytes)
    pdf_file.seek(0)
    return send_file(
        pdf_file,
        mimetype="application/pdf",
        as_attachment=False,
        download_name="report.pdf",
    )

def build_pdf_bytes():
    # Replace this with your PDF generator or storage lookup.
    return b"%PDF-1.4n..."

if __name__ == "__main__":
    app.run()

as_attachment=False produces inline-oriented metadata. Set it to True for a download; retain download_name to suggest the filename. A file-like object must be opened in binary mode (for example, BytesIO), not text mode.

Trusted filesystem file

from flask import send_file

@app.get("/reports/<report_id>.pdf")
def stored_report(report_id):
    path = lookup_trusted_report_path(report_id)  # Database/allow-list lookup.
    if path is None:
        return {"error": "Not found"}, 404
    return send_file(
        path,
        mimetype="application/pdf",
        as_attachment=True,
        download_name="report.pdf",
    )

Flask’s documentation prefers paths in most cases, but the path must come from trusted server-side resolution. Do not pass a request-supplied path directly to send_file.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Express: send a trusted file or a generated buffer

Serve a file with res.download

import express from "express";

const app = express();

app.get("/reports/:id/download", (req, res, next) => {
  const filePath = lookupTrustedReportPath(req.params.id);
  if (!filePath) return res.sendStatus(404);

  res.download(filePath, "report.pdf", { root: TRUSTED_REPORT_ROOT }, (err) => {
    if (err && !res.headersSent) next(err);
  });
});

app.listen(3000);

Express documents res.download(path, filename, options, callback). A root constraint can limit resolution, and the callback lets you distinguish an error that occurred before headers from one that occurred after transfer began. The example still performs an allow-listed lookup; a root option is not a reason to accept arbitrary path text.

Return a PDF buffer inline

app.get("/report.pdf", async (req, res, next) => {
  try {
    const pdfBuffer = await buildPdfBuffer();
    res.type("application/pdf");
    res.set("Content-Disposition", "inline; filename="report.pdf"");
    res.send(pdfBuffer);
  } catch (err) {
    next(err);
  }
});

Express’s res.send can transmit a buffer. Set the media type explicitly rather than relying on a filename or automatic inference.

NestJS: stream with StreamableFile

NestJS documents StreamableFile for returning a readable stream or buffer together with response metadata.

import { Controller, Get } from '@nestjs/common';
import { StreamableFile } from '@nestjs/common';
import { createReadStream } from 'node:fs';

@Controller('reports')
export class ReportsController {
  @Get('latest.pdf')
  getLatest(): StreamableFile {
    const stream = createReadStream('/srv/reports/latest.pdf');
    return new StreamableFile(stream, {
      type: 'application/pdf',
      disposition: 'inline; filename="latest.pdf"',
    });
  }
}

For a generated document, pass a readable stream or a buffer instead of createReadStream. NestJS describes adapter-specific stream error behavior for Express and Fastify. Attach appropriate error handling and decide what to do when the stream fails before versus after the response has started.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Framework-independent HTTP shape

Regardless of framework, the wire response has the same essential structure:

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: inline; filename="report.pdf"

<raw PDF bytes>

Do not base64-encode the body unless a separate protocol requires it. A normal PDF download response carries the binary bytes directly. If the request is not authorized, the document does not exist, or generation failed before transmission, return the appropriate non-success status and an error representation instead of pretending the body is a PDF.

Secure file selection

  • Map public IDs to records and server-controlled paths; do not concatenate a query parameter into a path.
  • Keep downloadable files below an approved directory and use the framework’s root restriction where available.
  • Check authorization before opening the file or starting the stream.
  • Use a controlled filename in Content-Disposition; avoid line breaks and path separators.
  • For private documents, apply the same authentication and authorization checks to every request, including cached or signed download URLs.

Performance and reliability decisions

When buffering is reasonable

In-memory bytes simplify generated reports and make it easy to set headers before sending. Use this when the document is appropriately sized for your process and concurrent requests will not exhaust memory.

When to prefer a path

A trusted path lets Flask, Express, or another framework manage file transfer without first copying the entire document into a new application buffer. It is a good fit when a PDF is already stored on disk.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When to stream

Stream when a PDF is large, generated incrementally, or coming from an upstream source. Plan for cancellation and stream errors. After the first bytes are sent, replacing the response with a clean JSON error is generally no longer possible; clients may receive a partial, unreadable PDF.

Verify what clients receive

Test both disposition modes with a real browser and an HTTP client. Confirm the status, Content-Type, Content-Disposition, filename, and that the body begins with valid PDF data. Test missing documents, unauthorized IDs, generator failures, client disconnects, and a file that is larger than normal.

Troubleshooting common failures

The browser downloads HTML or JSON instead of a PDF

Inspect the status code and headers. An authentication redirect, framework error page, or exception handler may be returning a non-PDF body. Ensure the success path sets application/pdf and that errors are handled before the PDF response starts.

The PDF viewer says the file is damaged

Check that the response body contains raw PDF bytes, that an in-memory stream is positioned at zero, and that no text encoding, debug output, or JSON wrapper was prepended. For streamed generation, investigate whether the stream failed partway through.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

send_file raises a file or mode error

Pass a valid server-side path or a binary file-like object. Do not open an in-memory PDF in text mode, and do not pass an unchecked user path.

Express returns a path error or exposes files

Replace direct path construction with an ID-to-path lookup and constrain the download root. Reject unknown IDs before calling res.download.

Headers cannot be changed

This means the response likely started already. Move validation, authorization, and PDF generation checks before writing headers or body bytes. For streams, handle errors according to the Express or Fastify adapter and accept that a late failure may leave a partial response.

Inline mode still downloads

Verify the exact Content-Disposition value and test the browser’s PDF-viewer and download settings. inline is a presentation signal, not a guarantee that every client will render in a tab.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your application needs a PDF or screenshot from a URL rather than a locally generated document, ScreenshotNeo returns a PDF from one HTTP call and can be used as the upstream source for your endpoint. Cookie banners, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the page verdict and billing status in X-Page-Verdict and X-Billed headers. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

See the parameter and PDF options in the ScreenshotNeo documentation. A direct cURL request is:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

For a PDF, adapt the request’s output option as documented and then return the resulting bytes from your own API with Content-Type: application/pdf and your chosen disposition. ScreenshotNeo has 1,000 free shots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

Python and Node.js request examples

These examples show how to retrieve a generated asset from an HTTP endpoint. They use the same binary rule: write the response content as bytes, not text.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Python

import requests

r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', bytes));

Frequently Asked Questions

Should I return a PDF as base64 in JSON?

Not for a normal browser download. Return the PDF bytes directly with Content-Type: application/pdf; use base64 only when a separate API contract specifically requires it.

Can I change from inline viewing to downloading later?

Yes. Keep the PDF body the same and change Content-Disposition from inline to attachment, optionally supplying a filename.

What should an API return when PDF generation fails?

If no PDF bytes have been sent, return your normal error status and error body. If streaming has already begun, the client may receive a partial document, so log the failure and handle it using your framework adapter’s stream-error mechanism.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.