Playwright & Web Automation Hub

Playwright architecture, selector reliability, and advanced interaction patterns.

Automating File Downloads and Verifying Contents

A test that clicks "Export CSV" and stops there proves nothing — the button might trigger an empty file, the wrong columns, or a stale report. The valuable assertion is on the bytes that land on disk. Playwright surfaces every download as a Download object delivered through the download event, and that object exposes the temporary file via download.path() and a copy helper via saveAs(). The recurring mistake is registering the listener after the click has already fired, so the event is missed and the test hangs until timeout. This page shows how to capture a download deterministically and then verify its size, name, and parsed contents.

Download capture and verification flow A click triggers a download event whose object yields a temp path and a saved copy that is then asserted on. Click export getByRole download event waitForEvent temp path() read bytes saveAs() persist copy assert bytes
Start waiting for the download before the click, take the temp path or save a copy, then assert on the actual bytes.

Root cause: the download event is missed if you wait too late

A download begins the instant the browser commits to saving a response rather than rendering it. Playwright emits a download event at that moment, but events are not buffered for listeners attached afterward — if you click() first and then call page.waitForEvent('download'), the event has already fired and your wait blocks until it times out. The fix is to start waiting and trigger the action together so the listener is armed before the event arrives. Once captured, the Download object points to a temporary file that Playwright deletes when the context closes, so you read it immediately via download.path() or copy it elsewhere with saveAs(). This sits within File Uploads & Downloads, under Advanced Interactions & Test Assertions.

Minimal reproducible example

The test exports a report, captures the download, and asserts on both the suggested filename and the parsed CSV contents read from the temporary path.

import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';

test('CSV export contains the expected rows', async ({ page }) => {
  await page.goto('/reports');

  // Arm the listener and fire the click in the SAME await so the
  // download event cannot arrive before we are waiting for it.
  const [download] = await Promise.all([
    page.waitForEvent('download'),
    page.getByRole('button', { name: 'Export CSV' }).click(),
  ]);

  // The server-suggested name, independent of where the file is stored.
  expect(download.suggestedFilename()).toBe('orders.csv');

  // path() resolves to the temp file once the download completes.
  const tempPath = await download.path();
  const csv = await readFile(tempPath, 'utf-8');

  // Assert on real contents: header plus a known data row.
  expect(csv).toContain('id,customer,total');
  expect(csv).toContain('1,Acme,120');
  expect(csv.trim().split('\n')).toHaveLength(3); // header + 2 rows
});

Step-by-step fix

  1. Arm waitForEvent('download') before the trigger. Wrap the wait and the click in a single Promise.all([...]) so the listener is attached before the click fires the download. This is the one ordering rule that prevents the timeout.
  2. Trigger the real download action. Click the export button or link inside the same Promise.all. Destructure the resolved array to get the Download object: const [download] = await Promise.all(...).
  3. Read the temporary file with download.path(). Awaiting path() blocks until the download finishes and returns the temp file location. Read it with fs/promises to get the bytes for assertions; this temp file is cleaned up when the context closes.
  4. Persist a copy with saveAs() when needed. Call await download.saveAs('/abs/path/orders.csv') to keep the file beyond the test — useful for artifacts attached to a report or for debugging a failure.
  5. Assert on contents, not just existence. Compare the suggested filename with suggestedFilename(), check the byte length or a hash for binary files, and parse text formats (CSV/JSON) to assert specific values. A non-empty file is the minimum bar, not the goal.
  6. Handle failed or cancelled downloads. Use download.failure() to surface an error string when a download did not complete, and increase actionTimeout for large files so path() is not cut off mid-transfer.

Troubleshooting variants

The test hangs and times out on waitForEvent

The listener was attached after the click, so the download event was missed. Move the wait into a Promise.all alongside the click, or call page.waitForEvent('download') and store the promise before triggering the action. Never await the click first and then wait for the event.

download.path() returns null or the file is empty

The download was still in flight, or the browser blocked it. Confirm the context allows downloads (it does by default with acceptDownloads enabled) and that you await download.path() so Playwright blocks until completion. If the file is genuinely empty, check download.failure() for an error and verify the server returned a body with a Content-Disposition: attachment header.

A new tab opens instead of downloading

The link targets _blank and the browser renders the response (a PDF, for example) rather than saving it. Either force the download attribute server-side, or capture the popup and read its response. For programmatic content checks you can also request the resource directly with the API request context and assert the bytes, bypassing the UI entirely. The inverse direction — pushing files into the page — is covered in Handling Multiple File Uploads in Playwright.

Verification

Confirm the download is captured and correct three ways. First, the content assertions pass across repeated runs (npx playwright test --repeat-each=10), proving the capture is not racing the transfer. Second, call saveAs() to a known path and open the artifact manually, or attach it to the report so a reviewer can inspect the exact bytes. Third, inspect the run in the Playwright Trace Viewer, where the download action and its completion appear on the timeline, confirming the event fired rather than timed out.

Frequently Asked Questions

Why does waitForEvent('download') time out?

Because the listener was attached after the click, so the download event had already fired and was not buffered. Wrap page.waitForEvent('download') and the click in a single Promise.all so the wait is armed before the action triggers the download.

What is the difference between download.path() and saveAs()?

download.path() returns the location of Playwright's temporary copy, which exists only until the context closes — read it immediately for assertions. saveAs(targetPath) copies the file to a permanent location you choose, which is what you want for artifacts or files that must outlive the test.

How do I verify the contents of a binary download?

Read the temp file as a Buffer with fs/promises, then assert on its length or a hash (for example a SHA-256 digest) against a known value, since byte-for-byte text comparison is meaningless for binary formats. For text formats like CSV or JSON, parse the file and assert specific values instead.

Back to overview