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

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

Fixing a Puppeteer PDF download requires checking two separate stages: PDF generation and HTTP delivery. page.pdf() creates PDF bytes (a Uint8Array), but it does not send those bytes to a browser. First verify that generation succeeds; then return the bytes with the correct response headers and finish the response.

1. Confirm that Puppeteer actually generated a PDF

Puppeteer’s Page.pdf() method generates a PDF using the print CSS media type and resolves to a Promise<Uint8Array>. The method can also save directly to a path. See the Page.pdf() API and PDF generation guide.

try {
  const pdf = await page.pdf({
    format: 'A4',
    printBackground: true
  });

  console.log('PDF bytes:', pdf.byteLength);
} catch (error) {
  console.error('PDF generation failed:', error);
}

If the call throws or the returned byte array is empty, the problem is not a download header. Investigate page navigation, rendering, fonts, scripts and failed resources first.

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

Use a path to isolate generation from delivery

As a diagnostic, save a copy locally:

await page.pdf({
  path: './debug-report.pdf',
  format: 'A4',
  printBackground: true
});

If this file opens correctly, generation works and your server route is the likely failure point. Puppeteer’s PDF guide notes that fonts are awaited by default, but custom fonts and late-loading page content can still make the rendered result appear incomplete if the page is captured too early.

2. Check navigation and page resources

A PDF can be generated from an error page, a partially loaded page or a page whose assets failed. Inspect the navigation response rather than assuming that a completed request means success.

const response = await page.goto(url, {
  waitUntil: 'networkidle0',
  timeout: 60_000
});

if (!response) {
  throw new Error('Navigation returned no response');
}

console.log({
  status: response.status(),
  ok: response.ok(),
  headers: response.headers()
});

Puppeteer’s Page.goto() documentation exposes status information through the returned HTTPResponse. A navigation response may be present even when the server returned an HTTP error.

Log failed requests

page.on('requestfailed', request => {
  console.error('Request failed:', request.url(), request.failure());
});

page.on('response', response => {
  if (response.status() >= 400) {
    console.warn('HTTP error:', response.status(), response.url());
  }
});

Puppeteer documents an important distinction in its HTTPRequest lifecycle: HTTP error responses such as 404 and 503 can still produce requestfinished. Treating that event as proof of a successful resource is therefore unsafe.

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

Wait for the content your PDF needs

For applications that render asynchronously, wait for a meaningful selector or application-ready signal before calling page.pdf():

await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#report-ready', { timeout: 30_000 });
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({ format: 'A4', printBackground: true });

Use a bounded timeout and fail clearly if the selector never appears. Avoid relying only on an arbitrary delay when the page has a deterministic readiness condition.

3. Return the PDF bytes as an HTTP response

When page.pdf() succeeds in memory, your route must send those bytes. Set headers before writing the body and call response.end() (or the equivalent framework method) to complete the message. Node’s HTTP documentation describes setHeader(), byte-oriented response bodies and end().

Plain Node.js HTTP example

import http from 'node:http';
import puppeteer from 'puppeteer';

const server = http.createServer(async (req, res) => {
  if (req.url !== '/report.pdf') {
    res.statusCode = 404;
    res.end('Not found');
    return;
  }

  let browser;
  try {
    browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.goto('https://example.com', {
      waitUntil: 'networkidle0',
      timeout: 60_000
    });

    const pdf = await page.pdf({
      format: 'A4',
      printBackground: true
    });

    res.statusCode = 200;
    res.setHeader('Content-Type', 'application/pdf');
    res.setHeader('Content-Disposition', 'attachment; filename="report.pdf"');
    res.setHeader('Content-Length', Buffer.byteLength(pdf));
    res.end(pdf);
  } catch (error) {
    console.error(error);
    if (!res.headersSent) {
      res.statusCode = 500;
      res.setHeader('Content-Type', 'application/json');
      res.end(JSON.stringify({ error: 'Could not generate PDF' }));
    } else {
      res.destroy(error);
    }
  } finally {
    await browser?.close();
  }
});

server.listen(3000);

The Content-Type identifies a PDF. Content-Disposition: attachment asks typical browsers to download it instead of displaying it inline and supplies the filename. Set Content-Length only from the actual byte length; do not calculate it from a JavaScript string representation.

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

Express example

import express from 'express';
import puppeteer from 'puppeteer';

const app = express();

app.get('/report.pdf', async (req, res, next) => {
  let browser;
  try {
    browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.goto('https://example.com', {
      waitUntil: 'networkidle0',
      timeout: 60_000
    });

    const pdf = await page.pdf({ format: 'A4', printBackground: true });
    res.status(200)
      .type('application/pdf')
      .set('Content-Disposition', 'attachment; filename="report.pdf"')
      .send(Buffer.from(pdf));
  } catch (error) {
    next(error);
  } finally {
    await browser?.close();
  }
});

app.listen(3000);

Express documents res.download(path) as a helper for transferring a file at a filesystem path. It is not a substitute for generating an in-memory Uint8Array. For an in-memory result, send a Buffer with the PDF and disposition headers as shown above. See the Express 4.x response API.

4. Inspect what the client actually received

Open the browser’s Network panel or use a command-line client against the route. Verify all of the following:

  • The status is successful rather than an HTML error response.
  • Content-Type is application/pdf.
  • Content-Disposition contains attachment and the intended filename.
  • The body is non-empty PDF data, not JSON, HTML or a stringified object.
  • If Content-Length is present, it matches the bytes transmitted.
curl -v http://localhost:3000/report.pdf -o report.pdf
file report.pdf

A valid PDF normally begins with the byte signature %PDF-. If file reports HTML or the file opens as corrupt, inspect the route’s error handling and body conversion.

5. Match the symptom to the failing layer

Symptom Likely layer What to do
page.pdf() throws Rendering or navigation Catch the error, inspect navigation status, log failed requests, and confirm the page reaches its ready state.
PDF bytes exist but the route returns 500 Server response Check headers, framework error handling and whether another middleware already sent a response.
Browser displays the PDF instead of downloading Disposition Use Content-Disposition: attachment; filename="...pdf" rather than inline behavior.
Downloaded file is HTML or JSON Error path or conversion Inspect status and body; send the original Uint8Array/Buffer, not JSON serialization or text decoding.
File is truncated or unreadable Byte length or incomplete response Calculate length in bytes, avoid premature connection closure, and ensure end() is called after writing the body.
PDF has missing images or styles Page readiness/resources Wait for selectors and fonts, log HTTP failures, and verify that asset URLs are reachable from the browser process.

6. Reliable production patterns

Reuse browser processes carefully

Launching a browser for every request is simple but expensive. A long-lived browser with a fresh page per job can reduce startup overhead; always close pages and browsers on errors. Limit concurrent PDF jobs so memory and CPU pressure does not cause timeouts.

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.

Use explicit timeouts and cancellation

Set navigation and selector timeouts appropriate to your application. Do not leave requests hanging while a page waits forever for an unavailable resource. Return a clear server error when generation exceeds the route’s deadline.

Keep downloads authenticated safely

If the source page requires authentication, provide credentials, cookies or headers to Puppeteer inside the server-side browser context. Never expose access tokens in the generated filename, URL query string or client-visible error response.

Choose inline versus attachment intentionally

Use inline when the browser should display the document in its PDF viewer; use attachment when the user should receive a download prompt. The filename is advisory, so sanitize user-provided names and include a safe .pdf extension.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server when you need a rendered page capture or PDF without maintaining Puppeteer infrastructure. One GET request returns a PNG, JPEG, WebP or PDF. Its cleanup step accepts cookie and consent banners and removes more than 60 known consent platforms, newsletter popups and chat widgets before capture; each step can be disabled. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and each response reports the result in X-Page-Verdict and X-Billed headers.

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

For PDF capture, use the API documented at ScreenshotNeo’s documentation. The following cURL example captures a URL; adapt the endpoint options for PDF output:

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

The same request in Python:

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)

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

ScreenshotNeo also includes an MCP server with take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. 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.

Best Value
The SQL Programming Language: .
  • Used Book in Good Condition

Version scope

The cited Puppeteer material covers version 25.12.0 documentation, Node’s HTTP reference is for v26.10.0, and the Express reference is the 4.x response API. Check the documentation matching the versions installed in your application, since method signatures, defaults and examples can change.

Frequently Asked Questions

Why does requestfinished not prove that my PDF page loaded correctly?

Puppeteer notes that HTTP error responses such as 404 and 503 can still trigger requestfinished. Check the response status and log failed requests instead.

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

Should I convert the Uint8Array returned by page.pdf() to a string?

No. Send the bytes directly or convert them to a Node Buffer. String conversion can corrupt the PDF.

When should I use a file path instead of an in-memory response?

Use a path for diagnostics or a separate file-transfer workflow. For a generated route response, sending the in-memory bytes avoids an unnecessary disk round trip.

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.