How to fix Playwright download tests that hang waiting for files?

Playwright

Download tests hang when the event listener is attached after the download has already started. The browser fires the download event synchronously as part of the response headers being processed — by the time your await page.waitForEvent('download') call runs after a await page.getByRole('button').click(), the event has already fired and the promise will never resolve. This is the canonical race condition in Playwright download testing, and page.waitForEvent('download') is the correct API — it just needs to be set up before the triggering action.

Common mistake

test('downloads invoice', async ({ page }) => {
  await page.goto('https://app.example.com/billing');

  // Click first — download event may have already fired by the next line
  await page.getByRole('button', { name: 'Download invoice' }).click();

  // This can hang indefinitely — event already happened
  const download = await page.waitForEvent('download');
});

The fix

Create the waitForEvent('download') promise before triggering the action, then await it after the click:

import { test, expect } from '@playwright/test';
import path from 'path';

test('downloads monthly invoice', async ({ page }) => {
  await page.goto('https://app.example.com/billing');

  // Set up the download listener BEFORE clicking
  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download invoice' }).click();

  // Now await the promise — it will resolve once the download is initiated
  const download = await downloadPromise;

  // Verify the filename
  expect(download.suggestedFilename()).toMatch(/invoice.*\.pdf/);

  // Save to a specific location for content verification
  const savePath = path.join('test-results', download.suggestedFilename());
  await download.saveAs(savePath);

  // Optionally verify file size is non-trivial
  const { size } = await download.failure() === null
    ? await import('fs').then(fs => fs.promises.stat(savePath))
    : { size: 0 };
  expect(size).toBeGreaterThan(1000);
});

For download links that open in a new page first:

test('exports report from new tab', async ({ page, context }) => {
  await page.goto('https://app.example.com/reports');

  // Listen for download on the context level when a new tab might be involved
  const downloadPromise = context.waitForEvent('page').then(async (newPage) => {
    return newPage.waitForEvent('download');
  });

  await page.getByRole('link', { name: 'Export full report' }).click();
  const download = await downloadPromise;

  expect(download.suggestedFilename()).toBe('report.csv');
});

Why it works

page.waitForEvent('download') registers an event listener on the Page object before any browser interaction occurs. When the browser starts a download — triggered by the server returning Content-Disposition: attachment headers or a navigation to a download URL — Playwright fires the download event on the page and the already-registered promise resolves. If the listener is registered after the click, the event fires in the gap between the two lines and is missed. Creating the promise first, then awaiting it after the click, guarantees the listener is in place when the event fires.

Tips

  • Use download.suggestedFilename() to get the filename from the Content-Disposition header without saving the file — useful for quick assertions that don't need the file content.
  • If the download requires checking file content, save with download.saveAs() and then read the file normally.
  • For downloads that require an intermediate confirmation dialog or step, the download event fires after the final navigation to the file URL — ensure your listener is set up before that navigation, not before the dialog.
  • If you need to test that a download does NOT happen under certain conditions, assert that the button is absent or disabled rather than waiting for an event that never fires.