Start waiting for the download before you click the control that triggers it. Then wait for the download to finish and copy it out of Playwright’s temporary directory before closing the browser context. This ordering prevents fast downloads from being missed and gives your test a durable file to inspect.
The reliable Playwright download sequence
In JavaScript or TypeScript, create the event promise first, perform the action second, and await the promise third:
const downloadPromise = page.waitForEvent('download');
await page.getByText('Download file').click();
const download = await downloadPromise;
await download.saveAs('/path/to/save/at/' + download.suggestedFilename());
page.waitForEvent('download') observes the page, while saveAs() copies the finished download to a path you control. Registering the wait before the click matters: a very fast response can emit the event before a wait created afterward is listening.
Why the event is not the same as completion
The download event means that a download has started. It is not, by itself, proof that all bytes have been written. saveAs() waits as necessary and is safe to call while the transfer is still in progress. Treat the returned Download object as the handle for the whole lifecycle.
#1 Best Overall
Use a deterministic output directory
Tests are easier to debug when each run writes to a known, isolated directory. Create that directory before the test, use suggestedFilename() for the server-provided name, and remove the file during test cleanup if it is not an artifact you want to retain.
JavaScript and TypeScript examples
Save a file and verify its contents
import { test, expect } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
test('downloads the invoice', async ({ page }, testInfo) => {
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 });
await page.getByRole('button', { name: 'Download invoice' }).click();
const download = await downloadPromise;
const output = testInfo.outputPath(download.suggestedFilename());
await download.saveAs(output);
const stat = await fs.stat(output);
expect(stat.size).toBeGreaterThan(0);
expect(path.extname(output)).toBe('.pdf');
});
The timeout is intentional: a missing download fails within 30 seconds instead of hanging until a broader test timeout. Adjust it to the slowest legitimate response in your environment.
Check for a failed download
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Export CSV' }).click();
const download = await downloadPromise;
const failure = await download.failure();
if (failure) {
throw new Error(`Download failed: ${failure}`);
}
await download.saveAs('./artifacts/export.csv');
A canceled or failed transfer should be reported as a test failure rather than treated as an empty file.
Choose one download when several can start
If a click can start multiple downloads, use the event predicate supported by your installed Playwright version to select the expected filename:
Do these 3 things before closing this tab:
1Repair Windows errors before they cause bigger problems2Fix the driver behind crashes, sound loss and screen glitches3Clear out junk files and repair common Windows errorsconst downloadPromise = page.waitForEvent('download', download =>
download.suggestedFilename().endsWith('.csv')
);
await page.getByRole('button', { name: 'Export' }).click();
const download = await downloadPromise;
await download.saveAs('./artifacts/result.csv');
Check the API reference for the exact predicate signature in your binding and version. A predicate prevents an unrelated download from satisfying the wait.
Observe downloads from any page in a context
When the page that starts the transfer is unknown, or several pages share one browser context, listen at context scope:
const downloadPromise = context.waitForEvent('download');
// An action on any page belonging to context can trigger the event.
await page.getByText('Download file').click();
const download = await downloadPromise;
await download.saveAs('./artifacts/file.bin');
Page-scoped waits are simpler when you know the source page; context-scoped waits are useful for multi-page workflows.
Temporary paths, filenames and remote browsers
Save before closing the context
Playwright stores downloads in a temporary location. Downloaded files are deleted when the browser context that produced them is closed. Call saveAs() before context.close() if the file must survive the test.
Outdated Drivers Are Slowing You Down
One free scan finds every outdated or missing driver and matches the right update for your exact hardware.Free scan · exact hardware matchPC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Rank #3
Do not rely on the temporary path
download.path() waits for completion and returns Playwright’s temporary path, but that path uses a random GUID rather than a meaningful filename. Use suggestedFilename() when naming an artifact. path() throws for failed or canceled downloads and has a documented limitation when Playwright is connected to a remote browser; saveAs() is the safer persistence API for that setup.
When to use createReadStream()
If your test needs to parse bytes without first choosing a destination, use the binding’s stream API after the download has started, then close the stream and still preserve a copy if later steps need a file. For ordinary end-to-end tests, saveAs() is less error-prone.
Python, Java and .NET syntax
Python
Python uses an expectation context manager around the triggering action:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
context = browser.new_context()
page = context.new_page()
page.goto("https://example.com")
with page.expect_download(timeout=30_000) as download_info:
page.get_by_role("button", name="Download file").click()
download = download_info.value
download.save_as("artifacts/" + download.suggested_filename)
browser.close()
In the async Python API, use async with page.expect_download() and await the click and save_as() calls.
Recommended Free Tools
Java
Download download = page.waitForDownload(() -> {
page.getByRole("button", new Page.GetByRoleOptions().setName("Download file")).click();
});
download.saveAs(Paths.get("artifacts", download.suggestedFilename()));
The callback performs the trigger while Playwright is already waiting.
.NET
var downloadTask = page.WaitForDownloadAsync();
await page.GetByRole(AriaRole.Button, new() { Name = "Download file" }).ClickAsync();
var download = await downloadTask;
await download.SaveAsAsync(Path.Combine("artifacts", download.SuggestedFilename));
Use the method names and overloads supplied by the Playwright package version installed in your project.
Timeouts and synchronization choices
Set a bounded timeout
Event waits inherit timeout settings from the page or browser context unless you provide one. Set a per-wait timeout when this operation has a different service-level expectation, or configure a project-wide timeout when all downloads share the same limit.
Wait for the right trigger
Do not replace a download wait with a fixed sleep. A sleep can be too short on a busy CI worker and waste time when the server is fast. The event identifies the browser’s actual download start; saveAs() then provides completion synchronization.
Handle downloads opened in a new page
If the click opens a new page before initiating the transfer, wait for the page and download events together, then perform the action that causes both. Keep each promise registered before the action so neither event can be missed.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Common failures and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Timeout waiting for download |
The control did not trigger a browser download, or the wait was registered after the click. | Create the wait first; verify the control’s behavior and inspect whether it navigates, opens a new tab, or renders content inline. |
| The file disappears after the test | Only the temporary Playwright file was used and the context closed. | Call saveAs() to a project-owned path before closing the context. |
path() throws |
The transfer failed, was canceled, or the browser is remote. | Check failure(); use saveAs() for persistence, especially with a remote connection. |
| The wrong file is saved | Several downloads satisfy an unfiltered event wait. | Use a predicate that checks suggestedFilename() or another expected property. |
| Saved file is zero bytes or incomplete | The event was treated as completion. | Await saveAs() (or another completion-aware API) before reading the file. |
| Filename contains unexpected characters | The server supplied a filename that differs by browser or response headers. | Log suggestedFilename(), then normalize or replace it with a test-owned name when portability matters. |
CI, performance and reliability practices
- Write artifacts under the test runner’s output directory so failed-run retention can collect them.
- Use unique names or per-test directories when workers run in parallel.
- Keep the browser context open until all assertions and file copies finish.
- Record the suggested filename and failure message in diagnostics; they often reveal a server-side response problem.
- For large files, avoid loading the entire file into memory. Save it directly and stream it to a parser or checksum routine.
- Use context-wide observation only when needed; page-scoped waits make ownership of a download clearer.
- Pin and review the Playwright version used by CI. “Next” documentation and API defaults can change, so confirm options against the version installed by your project.
Or skip the browser setup
If your goal is a rendered image or PDF of a web page rather than testing a user-initiated file transfer, ScreenshotNeo provides a direct HTTP endpoint. Its consent handling accepts cookie banners and removes more than 60 known consent platforms, newsletter popups and chat widgets before capture; each step can be disabled. Only clean shots are billed: bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits cost nothing, and the response identifies the result with X-Page-Verdict and X-Billed headers.
See the ScreenshotNeo API documentation for all options. A cURL request is:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
You can also use 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)
Or 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 has 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 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
Frequently asked questions
Can I wait after clicking?
It is unsafe. Register the event wait before the click so a fast download cannot be missed.
Does Playwright automatically keep the downloaded file?
No. The browser-context temporary file is removed when that context closes. Save a copy to a path you control.
Should I use a page or context download listener?
Use the page listener when one known page owns the action. Use the context listener when downloads may originate from several pages in the same context.
What does a download timeout mean?
It means the expected event did not occur within the configured limit. Check the trigger, event ordering, navigation behavior and server response before simply increasing the timeout.
Quick wins for a faster PC:
Clear out junk files and repair common Windows errorsFree Scan →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Quick Recap
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.

