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

Chrome does not let Puppeteer assign an arbitrary final filename through a documented high-level download handler. A reliable approach is to configure a dedicated download directory, trigger the download, wait for Chrome’s DevTools Protocol to report completion, verify the file, and then rename it with Node.js. Your script—not Chrome—must decide what to do when the destination name already exists.

How the download-and-rename workflow works

Puppeteer’s Files guide says: “Currently, Puppeteer does not offer a way to handle file downloads in a programmatic way.” That means you should not build this around an assumed Puppeteer method such as page.waitForDownload(). Instead, use a Chrome DevTools Protocol (CDP) session for download behavior and events, then use Node.js filesystem operations for the final name.

The sequence is important: create a controlled directory, configure Chrome before initiating the download, observe the download’s GUID and suggested name, wait for completion, identify and verify the downloaded file, and finally move it to a collision-safe destination. The browser’s suggested filename is useful metadata, not proof of the exact name on disk.

Choose a naming strategy

Approach Useful when Trade-off
Chrome’s allowAndName policy You want Chrome to save downloads under GUID-based names to avoid same-name collisions during the download stage. The GUID name is not descriptive. Your script still needs to map the download to a job and rename it afterward.
Chrome’s normal suggested name, then rename You want the server-proposed filename to inform the final name. The suggested name may not match the saved name, and your script remains responsible for collisions.

Neither approach is universally best. GUID names are convenient when several jobs run at once and you can retain the GUID-to-job mapping. Suggested names are convenient when a server’s filenames are meaningful and downloads are easy to associate with the action that started them. CDP marks allowAndName experimental, so verify support in the Chrome version and protocol used by your deployment.

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

Prepare Puppeteer on Ubuntu

Use a current, pinned Puppeteer release and browser version for repeatable automation. The Puppeteer v25.12.0 browser documentation describes Chrome dependency installation for Debian or Ubuntu. Installing system dependencies requires root privileges. In an environment where Puppeteer manages browser installation, use its documented browser installation tooling and install-dependency option as appropriate for your version; run the dependency installation step with the privileges needed to install system packages. Do not copy a generic package list or launch-flag recipe across Ubuntu releases and Chrome builds.

Create a directory that belongs to the job and use its absolute path. Avoid sharing a download directory between unrelated processes unless each process has an explicit way to correlate its downloads. A per-job directory makes it much less likely that a directory scan will select another run’s file.

Runnable Node.js example using CDP

The example below assumes Puppeteer is installed, Chrome can launch in the environment, and the target URL triggers a file download. Set TARGET_URL to the page containing the download link and adjust the selector. It uses allowAndName, listens for the CDP start and progress events, and chooses a non-overwriting destination name such as report-2.pdf. It deliberately checks the directory rather than trusting the completion event’s filePath.

import puppeteer from 'puppeteer';
import { mkdir, readdir, rename, stat } from 'node:fs/promises';
import path from 'node:path';

const targetUrl = process.env.TARGET_URL;
if (!targetUrl) throw new Error('Set TARGET_URL to the page that starts the download.');

const downloadDir = path.resolve('downloads', `job-${Date.now()}`);
await mkdir(downloadDir, { recursive: true });

const browser = await puppeteer.launch({ headless: true });
try {
  const page = await browser.newPage();
  const client = await page.createCDPSession();
  await client.send('Browser.setDownloadBehavior', {
    behavior: 'allowAndName',
    downloadPath: downloadDir,
    eventsEnabled: true,
  });

  let started;
  let resolveStarted;
  const startPromise = new Promise((resolve) => { resolveStarted = resolve; });
  let resolveFinished;
  const finishPromise = new Promise((resolve) => { resolveFinished = resolve; });

  client.on('Browser.downloadWillBegin', (event) => {
    started = event;
    resolveStarted(event);
  });
  client.on('Browser.downloadProgress', (event) => {
    if (event.state === 'completed' && started && event.guid === started.guid) {
      resolveFinished(event);
    }
    if (event.state === 'canceled' && started && event.guid === started.guid) {
      resolveFinished(new Error(`Download ${event.guid} was canceled`));
    }
  });

  await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
  await page.locator('a.download-link').click();

  const begin = await Promise.race([
    startPromise,
    new Promise((_, reject) => setTimeout(() => reject(new Error('No download started before timeout')), 30000)),
  ]);
  const completed = await Promise.race([
    finishPromise,
    new Promise((_, reject) => setTimeout(() => reject(new Error('Download did not complete before timeout')), 120000)),
  ]);
  if (completed instanceof Error) throw completed;

  // allowAndName uses the download GUID as the downloaded file's name.
  const source = path.join(downloadDir, begin.guid);
  await waitUntilStable(source);

  const suggested = safeFilename(begin.suggestedFilename || 'download');
  const destination = await availableName(downloadDir, suggested);
  await rename(source, destination);
  console.log(JSON.stringify({ guid: begin.guid, suggested: begin.suggestedFilename, savedAs: destination }));
} finally {
  await browser.close();
}

async function waitUntilStable(file, attempts = 20, intervalMs = 250) {
  let previousSize = -1;
  for (let i = 0; i < attempts; i++) {
    try {
      const info = await stat(file);
      if (info.isFile() && info.size === previousSize) return;
      previousSize = info.size;
    } catch (error) {
      if (error.code !== 'ENOENT') throw error;
    }
    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }
  throw new Error(`Downloaded file was not found or did not stabilize: ${file}`);
}

function safeFilename(input) {
  // Treat remote filename data as untrusted; discard directory components.
  const base = path.basename(input).replace(/[\/\0-\x1f]/g, '_').trim();
  return base && base !== '.' && base !== '..' ? base : 'download';
}

async function availableName(dir, filename) {
  const ext = path.extname(filename);
  const stem = path.basename(filename, ext);
  for (let n = 1; ; n++) {
    const candidate = path.join(dir, n === 1 ? `${stem}${ext}` : `${stem}-${n}${ext}`);
    try {
      await stat(candidate);
    } catch (error) {
      if (error.code === 'ENOENT') return candidate;
      throw error;
    }
  }
}

The example’s stability check is a practical extra verification after CDP reports completion: it waits for the file to exist and its size to stop changing across checks. It is not a formal guarantee against every unusual filesystem or browser failure. The code also assumes the GUID is the on-disk name for allowAndName; if the browser build behaves differently, inspect the dedicated directory and map the completed event to the file actually present before renaming.

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

Use a suggested name or handle multiple downloads

To use Chrome’s normal proposed filename instead, set the behavior to allow rather than allowAndName. The downloadPath is required for both policies. With allow, do not assume that joining the directory with suggestedFilename identifies the actual saved file: the suggested name is not guaranteed to be the final on-disk name, particularly when a duplicate exists. Inspect the controlled directory and correlate the new file with the download that just completed.

For simultaneous downloads, keep a record for each Browser.downloadWillBegin event: its GUID, suggested filename, URL, and the job context that caused the download. Match Browser.downloadProgress events by GUID, not by arrival order. In particular, do not wait for the first completion event and assume it belongs to the most recent click. If your job needs predictable naming, one download per isolated directory is simpler than attempting to infer which of several new files belongs to which action.

Set a duplicate policy that cannot silently clobber files

The example preserves existing files by probing for an available name and selecting a numbered suffix. Another sound policy is to include a stable task or record identifier, such as invoice-8472.pdf, which can make reruns easier to reconcile. Choose one policy explicitly instead of relying on Chrome’s duplicate naming behavior.

  • Sanitize filename input from a remote page. Remove path components and control characters, and do not let a proposed name escape the intended directory.
  • Preserve an extension when it is meaningful, but do not trust an extension as proof of the file’s actual content type.
  • Check whether the target exists before moving, and define whether a conflict should produce a suffix, fail the job, or be handled by an application-level replacement policy.
  • For concurrent workers, a check-then-rename sequence can race: two workers might choose the same unused name. Use a per-job directory or an atomic reservation strategy appropriate to your runtime and filesystem.

The example’s availability check is adequate for a single worker in its own directory, not a cross-process locking mechanism. Node.js and filesystem behavior can vary by version and filesystem, so do not treat a generic rename call as a portable overwrite-prevention guarantee.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Best Value
The SQL Programming Language: .
  • Used Book in Good Condition
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Timeouts, cleanup, and reliability

  • Set separate timeouts for “download began” and “download completed.” A page may load successfully without the click starting a download, and a started download may never finish.
  • Handle canceled downloads and browser shutdown. A timeout or cancellation should leave the source file for inspection or remove it according to your job’s retention policy; do not report a successful rename if the source was never verified.
  • Do not rely exclusively on the completion event’s filePath. CDP notes that it may be absent or may not refer to a file that exists. Directory inspection is an important fallback.
  • Log the GUID and suggested filename alongside the final path, while avoiding logs of sensitive URL parameters or file content.
  • Pin Puppeteer and browser versions in repeatable automation. The DevTools Protocol “tot” documentation tracks a moving protocol version, and allowAndName is experimental.

Common problems and fixes

Symptom Likely cause What to do
No downloadWillBegin event The selector did not trigger a download, navigation or consent UI blocked the action, or event handling was configured too late. Configure behavior before clicking, verify the selector and page state, and confirm the click starts a real download in the target browser.
Chrome rejects the download behavior command The installed browser/protocol may not support the requested policy, or the command parameters are incompatible. Check the browser’s CDP compatibility and use allow if GUID-based naming is not available in that build.
Completion arrives, but the file is missing The event’s filePath is not guaranteed to be present or valid, or the file has not appeared where expected. Check the configured absolute directory, inspect its entries, and verify the file before renaming. Treat missing output as failure, not success.
The saved name differs from the suggested filename The suggested name is metadata; Chrome’s final disk name may differ. Use the GUID mapping with allowAndName, or correlate the actual new file in an isolated directory before moving it.
Rename fails with a missing-file error The code guessed the source name, the download was canceled, or it has not finished writing. Match the event GUID, verify the file exists, and wait for stable size after completion before moving.
Two jobs select the same destination A name check and later rename are not an atomic reservation across workers. Use separate directories, serialize naming, or reserve destination names using a concurrency-safe mechanism.
Chrome fails to launch on Ubuntu Required system dependencies may be absent. Use Puppeteer’s documented dependency tooling for Debian/Ubuntu with the root privileges needed to install system packages; avoid assuming a package list fits every release.

Or skip the browser setup

ScreenshotNeo is a separate option if what you need is a webpage screenshot or PDF—not a downloaded file with a custom filename. Its API returns a screenshot or PDF from one GET request; it does not replace the Puppeteer download-and-rename workflow.

For a screenshot of a page, the cURL call is:

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 API documentation for request options. ScreenshotNeo removes cookie banners, newsletter popups and chat widgets before capture; bot checks, blank pages and failed loads are not billed. It also provides an MCP server for AI agents, and includes 1,000 screenshots per month free without a card; paid plans start at $5 for 3,000 screenshots. If the task is a webpage capture rather than a file download, learn about ScreenshotNeo and sign up for 1,000 free screenshots a month with no card.

Frequently Asked Questions

Does Puppeteer have a built-in waitForDownload method?

Puppeteer’s documented Files guide says it does not currently offer a programmatic download handler; use CDP events and filesystem checks instead.

Does allowAndName choose the final human-readable filename?

No. It uses a GUID-based name; your script must map the download and perform any descriptive rename.

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

Can I trust the download completion event’s filePath?

No. CDP says it may be absent or may not refer to an existing file, so verify the output in the configured directory.

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.