ScreenshotNeo

BlogHow-to

How to Fix “page._client.send Is Not a Function” When Setting Puppeteer’s Download Path

Fix Puppeteer’s page._client.send error, set a writable download folder, and handle Chrome CDP, public APIs, Firefox, and failed downloads.

By the ScreenshotNeo team1 October 20266 min read

Direct answer: page._client.send is not a function means your code is calling Puppeteer’s private page._client object after its internal shape changed. Create a dedicated Chrome DevTools Protocol (CDP) session and call send on that session, or use the public BrowserContext.setDownloadBehavior method. Use an existing writable directory, preferably with an absolute path, and wait for the download before closing the browser.

Why the error happens

Older Puppeteer examples used this private call:

await page._client.send('Page.setDownloadBehavior', {
  behavior: 'allow',
  downloadPath: './downloads',
});

page._client is an implementation detail rather than a stable API. Puppeteer issue #8640 documents the TypeError: page._client.send is not a function failure after an upgrade, including an example using Puppeteer 15.3.0, Node.js 16.15.1, and npm 8.13.2. The older download examples and related path problems are recorded in issues #1478 and #4676.

Fix 1: use a dedicated CDP session

Choose this route when you need to send a raw CDP command. It replaces the private client access with an explicit session:

const puppeteer = require('puppeteer');
const fs = require('fs/promises');
const path = require('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});

  const client = await page.target().createCDPSession();
  await client.send('Page.setDownloadBehavior', {
    behavior: 'allow',
    downloadPath,
  });

  await page.goto('https://example.com/file', {waitUntil: 'networkidle2'});
  await page.click('a[download]');

  // Wait for your application’s download-complete condition here.
  await new Promise(resolve => setTimeout(resolve, 2000));
  await browser.close();
})();

Depending on the Puppeteer release, page.createCDPSession() may also be available. Check the API for the version installed in your project before using that spelling.

Fix 2: use Puppeteer’s public browser-context API

Prefer the public method when your installed version provides it:

const puppeteer = require('puppeteer');
const path = require('path');
const fs = require('fs/promises');

(async () => {
  const browser = await puppeteer.launch({headless: true});
  const context = browser.defaultBrowserContext();
  const downloadPath = path.resolve(__dirname, 'downloads');
  await fs.mkdir(downloadPath, {recursive: true});

  await context.setDownloadBehavior({
    policy: 'allow',
    downloadPath,
  });

  const page = await context.newPage();
  await page.goto('https://example.com/file', {waitUntil: 'networkidle2'});
  await page.click('a[download]');

  await new Promise(resolve => setTimeout(resolve, 2000));
  await browser.close();
})();

The public method sends the browser-level Browser.setDownloadBehavior command with the policy, path, and context ID. The documented download behavior requires downloadPath when the policy is allow or allowAndName.

Which fix should you choose?

Situation Choice Reason
You only need to permit downloads BrowserContext.setDownloadBehavior Public API with less dependence on CDP details.
You must send another raw CDP command createCDPSession() plus client.send() Explicit session replaces the private page client.
You target Firefox through WebDriver BiDi Use the supported BiDi download operations Firefox BiDi does not provide Puppeteer’s CDP bridge.
Your code runs across several Puppeteer versions Feature-detect the public method and pin versions API availability and protocol behavior can vary by release.

Download-path configuration checklist

  1. Resolve an absolute path with path.resolve().
  2. Create the directory before launching or before setting the policy.
  3. Ensure the Chrome process user can write there.
  4. Keep the allow policy and downloadPath in the same configuration.
  5. Use a unique directory for concurrent jobs to prevent filename collisions.
  6. Wait until the download is complete before closing the page or browser.
  7. Check for temporary .crdownload files; their presence usually means the download is still in progress.

Waiting for a download reliably

A fixed delay is simple but can be too short on a slow network. A more reliable approach watches the directory and waits until a non-temporary file appears and its size stops changing:

const fs = require('fs/promises');
const path = require('path');

async function waitForDownload(dir, timeoutMs = 60000) {
  const deadline = Date.now() + timeoutMs;
  let lastName;
  let lastSize = -1;

  while (Date.now() < deadline) {
    const entries = await fs.readdir(dir, {withFileTypes: true});
    const files = entries.filter(e => e.isFile() && !e.name.endsWith('.crdownload'));
    if (files.length) {
      const candidate = files[files.length - 1].name;
      const fullPath = path.join(dir, candidate);
      const stat = await fs.stat(fullPath);
      if (candidate === lastName && stat.size === lastSize) return fullPath;
      lastName = candidate;
      lastSize = stat.size;
    }
    await new Promise(resolve => setTimeout(resolve, 250));
  }
  throw new Error('Timed out waiting for a completed download');
}

Common errors and fixes

Error or symptom Cause Fix
page._client.send is not a function Private client shape changed. Create a CDP session or call the public context method.
ENOENT for the download folder The directory does not exist. Run fs.mkdir(path, {recursive: true}) first.
Permission denied Chrome cannot write to the path. Use a writable directory and check the container or service account permissions.
Download never appears The click did not trigger a download, authentication failed, or the page is still loading. Confirm the selector, wait for navigation or the download event, and inspect the response in DevTools.
Only .crdownload remains The browser was closed before completion or the transfer failed. Wait for the temporary file to disappear and increase the timeout.
setDownloadBehavior is unsupported Browser/protocol mismatch or a non-Chrome target. Use Chrome/CDP for these commands, or use the target browser’s supported download API.
Files overwrite each other Concurrent pages share one folder and filenames. Allocate a per-job directory and rename completed files deterministically.

Protocol and browser compatibility

The CDP-session solution requires a browser connection that exposes Chrome DevTools Protocol. Puppeteer’s guidance states that Firefox WebDriver BiDi does not provide the CDP bridge, so a Firefox target needs supported BiDi operations instead. Do not assume a Chrome CDP command will work unchanged when switching browser protocols.

Performance, reliability, and cost considerations

  • Performance: Reuse one browser process when processing many downloads, but isolate jobs with separate contexts or directories. Avoid polling too frequently; a 250–500 ms interval is usually enough for file completion checks.
  • Reliability: Prefer an explicit completion condition over a fixed sleep. Record the final path, file size, and timeout reason so failed jobs can be retried safely.
  • Security: Treat downloaded files as untrusted input. Use a dedicated directory, validate expected filenames or MIME types, and avoid exposing the directory as a public web root.
  • Cost: Browser automation consumes CPU, memory, and bandwidth on the machine running Chrome. A managed screenshot or capture API can move that browser setup and scaling work out of your application.

Or skip the browser setup

If your goal is a clean screenshot rather than a downloaded browser file, ScreenshotNeo provides a single request-based capture API. Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor, and other MCP clients use take_screenshot, get_page_info, and capture_pdf.

See the ScreenshotNeo API documentation for all 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 fs = require('fs/promises');
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}`);
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

ScreenshotNeo includes full-page capture with lazy images loaded, element capture, device presets, custom CSS and JavaScript, waits, headers and cookies, blocking rules, caching, signed links, asynchronous jobs, bulk capture, PDFs, and HTML/CSS-to-image. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Is page._client safe to keep using?

No. It is private and can change between Puppeteer releases. Use a public browser-context method or an explicit CDP session.

Does downloadPath have to be absolute?

The contract requires a path when allowing downloads. An absolute path avoids ambiguity across working directories and is the safest choice in CI and containers.

Why does the browser close before my file is ready?

Navigation completion does not necessarily mean the file has finished writing. Wait for the completed file and verify that no temporary download file remains.

Can I use this CDP code with Firefox?

Not through Firefox WebDriver BiDi. Use Firefox’s supported BiDi download operations instead, or run the CDP code against Chrome.

What replaces Page.setDownloadBehavior?

For raw protocol work, create a CDP session and send the command there. For ordinary Puppeteer downloads, use BrowserContext.setDownloadBehavior when available.