---
title: "Fix flaky Playwright download, popup, and upload tests with Promise.all"
description: "Downloads, OAuth popups, and file uploads flake in Playwright when the event listener registers after the click. Pair them with Promise.all, and let Hover write the pairing for you."
date: 2026-06-07
author: "Hover"
tags: [playwright, flaky-tests, promise-all]
---

# Fix flaky Playwright download, popup, and upload tests with Promise.all

A download test passes ten times, then goes red on the eleventh with `Timeout waiting for event "download"`. Nobody changed the app. The test is racing itself.

This shows up on three flows every real app has: a button that downloads a file, a "Sign in with Google" popup, a file picker behind an upload control. Each one flakes for the same reason, and the fix is the same line of structure.

## Why the listener loses the race

Write a download test the way it reads in your head and you get this:

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

Click first, then wait. The trouble is timing. `click()` fires the download, and a fast download can resolve before the next line registers the listener. When that happens, `waitForEvent('download')` waits for an event that already fired, and you sit there until the 30-second timeout. On a slow CI box the order flips often enough to look random.

The popup and the file chooser have the identical shape. `click()` opens the popup, the `popup` event fires, and your `waitForEvent('popup')` arrives a beat too late.

## Pair the listener with the action

Register the listener before the click, and await both together:

```ts
const [download] = await Promise.all([
  page.waitForEvent('download'),
  page.getByRole('button', { name: 'Export CSV' }).click(),
]);
```

`Promise.all` starts the listener first, then fires the click. The event can't slip through, because something is already listening when it happens. The popup and the upload take the same pairing:

```ts
const [popup] = await Promise.all([
  page.waitForEvent('popup'),
  page.getByRole('button', { name: 'Continue with Google' }).click(),
]);

const [chooser] = await Promise.all([
  page.waitForEvent('filechooser'),
  page.getByText('Upload avatar').click(),
]);
await chooser.setFiles('avatar.png');
```

## Let Hover write the pairing

You know the rule now, but you still have to remember it on every download, popup, and upload you ever test. Hover removes that step.

When you crystallize a session, Hover's deterministic translator already emits popup and new-tab flows as `Promise.all` pairings. For a download or a file upload it leaves a `// hover:optimizable` marker on the spot, and the optional [optimization pass](/docs/features/optimization-pass) fills it in, taking the exact pairing from the [seed library](/docs/features/seed-library). You read the diff and keep it. The spec that ships is plain Playwright with the race already designed out.

[See how Hover structures a spec →](/docs/features/structured-output)
