Recommended Free Tools
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
To download a PDF with Puppeteer, configure a writable download directory and an explicit download policy before clicking the control. Wait for the click’s actual outcome—navigation, a download request, or a PDF viewer—and verify that a non-empty file was written. The following Node.js example covers the common cases without confusing downloading an existing PDF with generating a new one.
What Puppeteer is doing
Puppeteer controls Chrome or Firefox through the DevTools Protocol or WebDriver BiDi. A button labelled “Download PDF” can produce several different browser behaviors:
- Normal download: the server returns a PDF with download headers, and Chrome writes it to the configured directory.
- Navigation to a PDF: the browser opens the document, often in its built-in PDF viewer. This is navigation, not a normal download event.
- Generated PDF: page JavaScript creates content or calls an endpoint after the click. You must observe the resulting request or response.
Identify which behavior the site uses before choosing the wait strategy. A click alone does not guarantee that a file will appear.
Complete Puppeteer example for a download button
Install Puppeteer
npm install puppeteer
Create download-pdf.js. Replace the URL and selector with values from the site you automate.
#1 Best Overall
const puppeteer = require('puppeteer');
const fs = require('node:fs/promises');
const path = require('node:path');
(async () => {
const browser = await puppeteer.launch({headless: true});
const page = await browser.newPage();
const downloadPath = path.resolve(__dirname, 'downloads');
await fs.mkdir(downloadPath, {recursive: true});
// Set this before navigation and before the click.
await page.browserContext().setDownloadBehavior({
policy: 'allow',
downloadPath
});
await page.goto('https://example.com/invoice', {
waitUntil: 'domcontentloaded',
timeout: 60000
});
const button = page.locator('button[data-download="pdf"]');
await button.click();
// A normal download does not necessarily navigate. Allow the browser
// time to finish writing, then inspect the directory at application level.
const file = await waitForPdf(downloadPath, 60000);
const stat = await fs.stat(file);
if (stat.size === 0) throw new Error(`Empty PDF: ${file}`);
console.log(`Saved ${file} (${stat.size} bytes)`);
await browser.close();
})().catch(error => {
console.error(error);
process.exitCode = 1;
});
async function waitForPdf(directory, timeoutMs) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const names = await fs.readdir(directory);
const completed = names.filter(name => /.pdf$/i.test(name));
const temporary = names.some(name => /.crdownload$|.tmp$/i.test(name));
if (completed.length && !temporary) {
return path.join(directory, completed[completed.length - 1]);
}
await new Promise(resolve => setTimeout(resolve, 250));
}
throw new Error('Timed out waiting for a completed PDF');
}
The download behavior API requires downloadPath when the policy is allow or allowAndName. The directory must exist and be writable by the process. The code deliberately checks the filesystem because Puppeteer does not prescribe one universal filename or completion marker.
Wait correctly for navigation-triggering clicks
If the button changes the page location, register the navigation wait before clicking. Waiting afterward can race with a fast navigation.
const [response] = await Promise.all([
page.waitForNavigation({waitUntil: 'networkidle2', timeout: 60000}),
page.locator('button[data-download="pdf"]').click()
]);
if (!response) throw new Error('The click did not produce a navigation response');
console.log('Navigated to', response.url());
Use this pattern only when navigation is expected. A normal download can leave the current page unchanged, so a navigation wait may time out even though the PDF is being saved. For non-navigation cases, observe page events such as request, response, or requestfinished, then verify the completed file.
Free tools Windows power users keep installed
One-click scans. No signup required.
Rank #2
Observing the request that starts a download
const downloadRequest = new Promise(resolve => {
page.once('requestfinished', request => {
if (/.pdf(?:$|[?#])/i.test(request.url())) resolve(request);
});
});
await page.locator('button[data-download="pdf"]').click();
const request = await Promise.race([
downloadRequest,
new Promise((_, reject) => setTimeout(() => reject(new Error('PDF request timed out')), 60000))
]);
console.log('PDF request finished:', request.url());
The request event confirms network activity; it does not replace checking that Chrome finished writing the file. If the site uses a blob, service worker, or a URL without a .pdf suffix, match the request using a stable path, response header, or the button’s known endpoint instead.
Choose a stable locator and wait for readiness
Puppeteer locators wait for an element to exist and reach an actionable state. Prefer an accessible name, stable data attribute, or a selector tied to the real control rather than a generated class name.
await page.locator('button[data-download="pdf"]').click();
// Other examples:
await page.getByRole('button', {name: /download pdf/i}).click();
await page.getByText('Download PDF', {exact: true}).click();
If the button is inside an iframe, obtain the frame and locate the control there. If it appears after authentication or client rendering, wait for a selector that proves the page is ready, then click. A selector timeout usually means the wrong frame, an incomplete login, a cookie overlay, or a selector that changed—not that the PDF itself is unavailable.
When the click opens Chrome’s PDF viewer
A PDF viewer navigation is different from a normal download. Handle the response or navigation and account for the browser mode you selected. Puppeteer documents that headless shell mode does not support navigation to a PDF document; direct PDF navigation may therefore require response-level handling or a browser mode that supports the site’s behavior.
If you already know the PDF URL, request the response and save its bytes in Node.js instead of waiting for a download event:
const response = await page.waitForResponse(
r => r.request().isNavigationRequest() && r.headers()['content-type']?.includes('application/pdf'),
{timeout: 60000}
);
const data = await response.buffer();
await fs.writeFile(path.join(downloadPath, 'document.pdf'), data);
if (!data.length) throw new Error('The PDF response was empty');
Adapt the predicate to the site. Some servers omit or vary Content-Type, and some viewer URLs contain a fragment or a wrapper page rather than the PDF bytes.
Rank #4
Download versus generate: use the right API
| Requirement | Correct approach | Why |
|---|---|---|
| Retrieve the server’s existing PDF after a user click | Configure download behavior; handle navigation or request events; verify the saved file | You need the original response, not a re-rendered page |
| Create a PDF of the currently rendered HTML | await page.pdf({path: 'page.pdf', format: 'A4', printBackground: true}) |
Puppeteer’s PDF guide says, “For printing PDFs use Page.pdf().” |
| Button opens a PDF viewer | Handle PDF navigation or capture the PDF response; avoid assuming a download event | Viewer navigation and file downloads are separate browser flows |
Page.pdf() does not click a site’s download button and does not guarantee the same bytes, metadata, or server-side document as an existing PDF.
Common failures and fixes
No file appears
- Confirm
setDownloadBehaviorran before the click. - Check that
downloadPathis absolute, exists, and is writable. - Determine whether the click navigated or opened a viewer instead of starting a normal download.
- Look for temporary
.crdownloadfiles, which indicate an incomplete write.
Intermittent timeout
Start the wait before clicking. Use Promise.all for navigation-triggering controls, and use a request or filesystem wait for ordinary downloads. Do not use an arbitrary short sleep as the only completion test.
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Clear out junk files and repair common Windows errorsFree Scan →The wrong element is clicked
Inspect the rendered DOM and choose an accessible name or stable attribute. Handle consent dialogs, authentication, overlays, and iframes explicitly. A visible text match can select a menu item or a disabled duplicate instead of the download control.
Best Value
- Used Book in Good Condition
The PDF is empty or not really a PDF
Check file size and, when needed, inspect the first bytes for the PDF signature %PDF-. A login page, bot challenge, HTML error, or JSON response can be saved with a .pdf filename if the endpoint failed. Capture status, content type, and response URL for diagnosis.
The browser closes too early
Keep the browser open until the request has finished and the filesystem shows a completed, non-empty file. Close it in a finally block in production code so failures do not leak processes.
Headless mode cannot open the PDF
If the site relies on PDF-viewer navigation, do not treat the headless-shell limitation as a download failure. Capture the PDF response directly or use a supported browser mode, then save the response bytes yourself.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Reliability, security, and cost considerations
- Use a unique directory per job when several downloads can run concurrently; otherwise one job can mistake another job’s PDF for its own.
- Record the originating URL, HTTP status, content type, and final filename. This makes redirects and authentication failures visible.
- Sanitize server-provided filenames before moving them outside the temporary directory.
- Set realistic navigation and download timeouts, but let the completion condition be a finished request and stable file, not elapsed time alone.
- Reuse a browser for batches when appropriate, while isolating cookies and download directories between accounts or tenants.
Or skip the browser setup
If your requirement is to capture a page or PDF URL rather than reproduce a site’s button interaction, ScreenshotNeo provides a one-call API. It is not a replacement for a protected workflow that requires clicking a button, but it can avoid maintaining Chromium and download-directory code when the target URL is directly addressable.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
See the ScreenshotNeo documentation for the parameters and PDF options. Equivalent clients are:
Quick Recap
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}`);
ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server lets Claude, Cursor, and other MCP clients use take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Sign up free.
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.

