Download and upload files with Puppeteer: paths and completion
Select files through real inputs and configure browser downloads with an owned directory, exact-byte completion checks and finite deadlines.
Use a direct authorized HTTP request when you only need known bytes. Use a browser download when a page interaction/session is genuinely required. For file selection, target the real <input type="file"> or register a chooser wait before opening it.
Select and download files step by step
Run npm install puppeteer in your project. We will create a sample file, select it through a real input, and download a small report. The snippets show the key steps; the complete script below supplies the browser and inline page, and closes the browser and context on success or failure.
1. Create a sample file in an owned directory
A fresh temporary directory keeps this job separate from older downloads. Write the upload sample there and define the report bytes we expect to receive.
import puppeteer from "puppeteer";
import {
mkdtemp,
writeFile,
readFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const directory = await mkdtemp(
join(tmpdir(), "puppeteer-files-"),
);
const sample = join(directory, "sample.txt");
const expected = Buffer.from("Sample report\n");
await writeFile(sample, "Synthetic upload content.\n");
2. Choose the download directory
After launching the browser, create a context with an explicit download policy and path. This tells Chrome where to save files; it does not tell us when a transfer is complete. The complete script creates the page and its file input inside this context.
const context = await browser.createBrowserContext({
downloadBehavior: {
policy: "allow",
downloadPath: directory,
},
});
3. Select the upload file and inspect it
On the inline page, find the real file input and select the sample. Release the handle afterward. Reading the selected text confirms selection, not that a server received an upload.
const input = await page.$("#file");
if (!input) {
throw new Error("Required input absent");
}
try {
await input.uploadFile(sample);
} finally {
await input.dispose();
}
const selectedText = await page.$eval(
"#file",
async (input) => input.files[0].text(),
);
console.log(selectedText);
4. Create the report link and click it
For this small demo, a Blob supplies the known report content. Attach its URL to the page’s download link, then click the link.
await page.$eval("#download", (link) => {
const blob = new Blob(["Sample report\n"], {
type: "text/plain",
});
link.href = URL.createObjectURL(blob);
});
await page.locator("#download").click();
5. Wait for the expected bytes, with a deadline
The sample has a known filename and content. Poll only that destination until its bytes match, and stop after five seconds. A missing file is expected while the transfer starts; other filesystem errors must still fail the task.
const destination = join(directory, "report.txt");
const deadline = Date.now() + 5000;
let complete = false;
while (Date.now() < deadline) {
try {
const downloaded = await readFile(
destination,
);
if (downloaded.equals(expected)) {
complete = true;
break;
}
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
await delay(50);
}
After the polling loop, reject an incomplete download instead of reporting success:
if (!complete) {
throw new Error(
"Download incomplete at deadline",
);
}
console.log(destination);
Complete script
Here are all the pieces together, including setup and cleanup. Save this as files.mjs and run node files.mjs.
import puppeteer from "puppeteer";
import {
mkdtemp,
writeFile,
readFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const directory = await mkdtemp(
join(tmpdir(), "puppeteer-files-"),
);
const sample = join(directory, "sample.txt");
const expected = Buffer.from("Sample report\n");
await writeFile(sample, "Synthetic upload content.\n");
const browser = await puppeteer.launch({
headless: true,
});
try {
const context = await browser.createBrowserContext({
downloadBehavior: {
policy: "allow",
downloadPath: directory,
},
});
try {
const page = await context.newPage();
await page.setContent(`
<input id="file" type="file">
<button
id="choose-file"
onclick="
document.querySelector('#file').click()
"
>
Choose
</button>
<a id="download" download="report.txt">
Download
</a>
`);
const input = await page.$("#file");
if (!input) {
throw new Error("Required input absent");
}
try {
await input.uploadFile(sample);
} finally {
await input.dispose();
}
const selectedText = await page.$eval(
"#file",
async (input) => input.files[0].text(),
);
console.log(selectedText);
await page.$eval("#download", (link) => {
const blob = new Blob(["Sample report\n"], {
type: "text/plain",
});
link.href = URL.createObjectURL(blob);
});
await page.locator("#download").click();
const destination = join(directory, "report.txt");
const deadline = Date.now() + 5000;
let complete = false;
while (Date.now() < deadline) {
try {
const downloaded = await readFile(
destination,
);
if (downloaded.equals(expected)) {
complete = true;
break;
}
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
await delay(50);
}
if (!complete) {
throw new Error(
"Download incomplete at deadline",
);
}
console.log(destination);
} finally {
await context.close();
}
} finally {
await browser.close();
}
The selected file’s text is printed, followed by the absolute downloaded file path. Selecting a file is not submitting it to a server. A real upload also needs the authorized application’s submit/response/success checks; see forms.
The generated files remain in the printed temporary directory for inspection. Closing the browser does not delete output files; apply your application’s explicit retention policy to its own job directories.
File inputs and choosers
uploadFile() uses paths available to the automation runtime, not a remote client’s filesystem. Uploading several files requires an input with multiple; do not change the application’s attribute just to bypass validation.
For the inline page above, this is an alternative selection path, inside its existing page lifecycle:
const [chooser] = await Promise.all([
page.waitForFileChooser({
timeout: 5000,
}),
page.locator("#choose-file").click(),
]);
await chooser.accept([sample]);
Register the wait before the action and accept/cancel the chooser once. Competing listeners can cause already-handled errors.
Download policy is not completion
The context’s downloadBehavior sets the download policy and path. Use an absolute, writable directory owned by your application, separate for each job. The allowAndName policy is not supported with WebDriver BiDi.
The script knows its expected filename and bytes, and starts with an empty directory. Its finite polling is a narrow known-file strategy, not a completion API for arbitrary names/content. An old matching file, .crdownload file or nonzero byte count is not proof a new transfer completed.
For unknown downloads, use the chosen browser/protocol’s documented notifications plus integrity/deadline/collision handling. Do not invent a universal Puppeteer page.on('download') event.
Direct HTTP and session limits
For a stable authorized URL, check status, type and size, then write only into your owned destination. Stream large files with limits rather than buffering indefinitely. Cookies/tokens must stay scoped to the intended origin; do not forward all browser session data to redirects or unrelated hosts.
This example uses Puppeteer with Chrome and creates a small file in the page. Real applications may add authentication, filename collisions or download deadlines; handle those explicitly, clean up job files and keep the sandbox enabled.