ScreenshotNeo

BlogHow-to

How to Download Files with Playwright Tests

Learn the reliable Playwright pattern for waiting on downloads, saving files, checking filenames, and avoiding flaky tests in JavaScript, Python, and Java.

By the ScreenshotNeo team1 October 20268 min read

How to Download Files with Playwright Tests

Register the download wait before the click that starts the file. Await the resulting Download object, then copy it to a deterministic path with saveAs (or save_as in Python). This ordering prevents races, gives your test a stable artifact, and lets you assert the filename and file contents.

Playwright downloads belong to the browser context. Their temporary files are removed when that context closes, so save anything you need before teardown.

JavaScript and TypeScript: wait, save, assert

The event-before-action sequence is the essential pattern:

Register the download event before the action, then save the completed artifact.
Register the download event before the action, then save the completed artifact.
import { test, expect } from '@playwright/test';
import path from 'node:path';

 test('downloads the invoice', async ({ page }, testInfo) => {
  await page.goto('https://example.test/invoices');

  const destination = path.join(testInfo.outputDir, 'invoice.pdf');
  const downloadPromise = page.waitForEvent('download');

  await page.getByRole('link', { name: 'Download invoice' }).click();

  const download = await downloadPromise;
  await download.saveAs(destination);

  expect(download.suggestedFilename()).toMatch(/\.pdf$/i);
  await expect.poll(async () => {
    return (await import('node:fs/promises')).stat(destination).then(() => true).catch(() => false);
  }).toBe(true);
});

waitForEvent('download') starts listening immediately. The click follows it, so a download that begins synchronously cannot be missed. saveAs may be called while the transfer is still in progress; Playwright waits as needed before copying the completed file.

Using a temporary path

const downloadPromise = page.waitForEvent('download');
await page.getByText('Download file').click();
const download = await downloadPromise;

const temporaryPath = await download.path();
console.log(temporaryPath);

path() waits for completion and returns Playwright’s temporary path. The name is a random GUID, so use suggestedFilename() when the original name matters. A failed or canceled download causes path() to throw.

Handling a filename safely

import path from 'node:path';

const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;

const filename = download.suggestedFilename();
if (!filename.toLowerCase().endsWith('.csv')) {
  throw new Error(`Unexpected download filename: ${filename}`);
}

const destination = path.join(testInfo.outputDir, filename);
await download.saveAs(destination);

Treat the suggested name as untrusted input. Keep it inside a test-controlled directory and reject path separators if your application can return arbitrary names.

Python: synchronous and asynchronous tests

Sync Python

from pathlib import Path
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.test/invoices")

    output = Path("test-results/invoice.pdf")
    output.parent.mkdir(parents=True, exist_ok=True)

    with page.expect_download() as download_info:
        page.get_by_role("link", name="Download invoice").click()

    download = download_info.value
    assert download.suggested_filename.endswith(".pdf")
    download.save_as(output)
    assert output.exists()

    browser.close()

The initiating action must be inside the expect_download() context. Exiting the context yields the completed event object.

Async Python

import asyncio
from pathlib import Path
from playwright.async_api import async_playwright

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        context = await browser.new_context()
        page = await context.new_page()
        await page.goto("https://example.test/invoices")

        output = Path("test-results/invoice.pdf")
        output.parent.mkdir(parents=True, exist_ok=True)

        async with page.expect_download() as download_info:
            await page.get_by_role("link", name="Download invoice").click()

        download = await download_info.value
        await download.save_as(output)
        assert output.exists()
        await browser.close()

asyncio.run(main())

A listener such as page.on("download", handler) is useful when the initiator is unknown, but it forks control flow. Make the handler’s work awaitable or the test can finish before the file is saved.

Java: use waitForDownload

import com.microsoft.playwright.*;
import java.nio.file.*;

public class DownloadTest {
  public static void main(String[] args) throws Exception {
    try (Playwright playwright = Playwright.create()) {
      Browser browser = playwright.chromium().launch();
      BrowserContext context = browser.newContext();
      Page page = context.newPage();
      page.navigate("https://example.test/invoices");

      Download download = page.waitForDownload(() -> {
        page.getByRole(AriaRole.LINK,
            new Page.GetByRoleOptions().setName("Download invoice")).click();
      });

      Path output = Paths.get("test-results", "invoice.pdf");
      Files.createDirectories(output.getParent());
      download.saveAs(output);
      if (!Files.exists(output)) {
        throw new AssertionError("Download was not saved");
      }

      context.close();
      browser.close();
    }
  }
}

Java exposes the same lifecycle: suggestedFilename(), path(), saveAs(), and (where supported by the binding) failure().

What the Download object contains

Method or property Use Important behavior
url() Inspect the URL that produced the download Useful when several export endpoints exist
suggestedFilename() Preserve or validate the server-provided name Often derived from Content-Disposition or the HTML download attribute
path() Get Playwright’s temporary completed path Throws for failed or canceled downloads; temporary files disappear with the context
saveAs(path)/save_as(path) Copy to a durable test path Safe while the download is still progressing
failure() Inspect a failed transfer Use it to turn an opaque failure into an explicit test error when available

Assertions that make download tests trustworthy

  1. Assert that the destination exists after saveAs completes.
  2. Check the extension or suggested filename when the endpoint can return multiple formats.
  3. For important exports, inspect content: parse JSON or CSV, read PDF metadata, or compare a checksum appropriate to your fixture.
  4. Check that an empty file is not accepted when the application promises content.
  5. Use a unique output directory per test. Playwright Test’s testInfo.outputDir avoids collisions in parallel workers.
import { readFile } from 'node:fs/promises';

const bytes = await readFile(destination);
expect(bytes.length).toBeGreaterThan(0);
expect(bytes.subarray(0, 4).toString()).toBe('%PDF');
Temporary downloads disappear with the browser context unless you save them first.
Temporary downloads disappear with the browser context unless you save them first.

Common download scenarios

Button starts the download

const pending = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export' }).click();
const download = await pending;
await download.saveAs('artifacts/export.csv');

Download attribute on an anchor

const pending = page.waitForEvent('download');
await page.locator('a[download]').click();
const download = await pending;
console.log(download.suggestedFilename());

Download opens after a menu interaction

const pending = page.waitForEvent('download');
await page.getByRole('button', { name: 'More actions' }).click();
await page.getByRole('menuitem', { name: 'Download ZIP' }).click();
const download = await pending;
await download.saveAs('artifacts/archive.zip');

Several downloads from one action

const downloads = Promise.all([
  page.waitForEvent('download'),
  page.waitForEvent('download')
]);
await page.getByRole('button', { name: 'Download selected files' }).click();
const [first, second] = await downloads;
await first.saveAs('artifacts/first.bin');
await second.saveAs('artifacts/second.bin');

Prefer a separate, explicit wait for each expected file when the order is stable. If order is not guaranteed, match each object by suggestedFilename() before saving.

Downloads in a popup or new page

The download event is dispatched by the page that initiates it. If a click opens a popup, wait for both events and identify which page owns the download:

const popupPromise = page.waitForEvent('popup');
const downloadPromise = page.waitForEvent('download');
await page.getByText('Generate report').click();
const popup = await popupPromise;
const download = await downloadPromise;
await popup.waitForLoadState();
await download.saveAs('artifacts/report.xlsx');

Browser and context configuration

Playwright supports a browser launch downloadsPath option to configure where downloaded files are persisted. Even with that option, save important artifacts explicitly because context closure removes downloads belonging to that context.

import { chromium } from 'playwright';

const browser = await chromium.launch({
  downloadsPath: 'playwright-downloads'
});
const context = await browser.newContext({ acceptDownloads: true });

Keep download acceptance enabled in the context used by the test. Use isolated contexts for parallel tests so cookies, authentication, and temporary files cannot leak across cases.

Troubleshooting

Symptom Likely cause Fix
Timeout waiting for download The click did not trigger a download, or the listener was attached afterward Register the wait first; verify the locator, permissions, and that the response has a download disposition
Test hangs after clicking The page opened a new tab, navigated, or returned an inline response Wait for the relevant popup or response and inspect the resulting URL and headers
path() throws The transfer failed or was canceled Check failure(), server logs, authentication, and network access; do not close the context early
File disappears after the test Only the temporary path was used Call saveAs/save_as before context teardown
Filename is a random GUID You asserted the temporary path name Use suggestedFilename() for the server’s intended name
Parallel tests overwrite files All tests use one fixed destination Use a per-test directory or include the test identifier in the filename
Zero-byte or unexpected file Export failed inside the application or the test saved an error response Assert size and format, then inspect response status and content before accepting the artifact
Python test ends before saving A page.on handler launched asynchronous work that was not awaited Use expect_download around the initiating action, or explicitly await the handler’s task

Performance, reliability, and cost

  • Wait on the event, not an arbitrary sleep. Event synchronization finishes as soon as the download starts and avoids guessing how long a server will take.
  • Save once. Copy directly to the final test artifact path instead of repeatedly reading and rewriting the temporary file.
  • Keep fixtures small. Use a deterministic test export for routine checks; reserve large-file tests for a separate coverage case.
  • Parallelize safely. Separate browser contexts and output directories prevent collisions. Ensure the test server can handle concurrent export requests.
  • Retry at the right layer. A retry can hide a real export defect. Record the download failure and server response before retrying transient infrastructure errors.
  • Clean up deliberately. Test-runner output directories can be retained as CI artifacts on failure and removed on successful runs.
  • There is no Playwright download fee. Your costs come from browser execution, CI time, storage, and the application endpoint that generates the file.

Or skip the browser setup

If your goal is a rendered page image or PDF rather than testing your own download workflow, ScreenshotNeo provides a single HTTP request. It removes cookie and consent banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and responses identify the result with X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf.

See the ScreenshotNeo API documentation for all capture 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 q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

The free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

FAQ

Why must the wait be registered before clicking?

The download event can fire immediately when the action starts. Registering afterward creates a race in which the event has already been dispatched.

Should I use path() or saveAs()?

Use saveAs() for a durable, named artifact. Use path() when you only need to inspect the temporary completed file during the current context.

Can I assert the original filename?

Yes. Read suggestedFilename() (or Python’s suggested_filename) and compare it with the expected extension or exact fixture name.

When is a download deleted?

Playwright deletes downloads belonging to a browser context when that context closes. Save required files before teardown.

What if the application downloads conditionally?

Keep the wait around the action, then inspect the filename, URL, and failure state. If no download is expected for a branch, test that branch separately instead of using one unconditional wait.