PRACTICAL GUIDE / Playwright browserContext download event
Catch the download even when another tab starts it
Catch downloads started by popups or secondary tabs, correlate each event to its source page, verify saved bytes, and retain useful CI evidence.
In this guide6 sections
What you will learn
- Decide whether the page or context owns the wait
- Capture a known download without losing the race
- Correlate downloads when several pages are active
- Diagnose an event that arrived but never completed
The export opens in a popup, the CSV lands in a temporary folder, and the test still times out on the original page. Nothing is wrong with the download. The wait is listening at the wrong scope.
Decide whether the page or context owns the wait
Playwright has long exposed a page-level download event. That remains the clearest choice when one known page and one known action own the file. Version 1.60 added a corresponding event on BrowserContext. It is emitted when an attachment download starts in any page belonging to that context. This repository pins Playwright 1.61.1, so the context-level event and its TypeScript types are available here.
Scope is the important difference. A waiter on the dashboard page does not become a waiter on every popup simply because the popup came from the dashboard. A context waiter sees downloads from the dashboard, its popup, and any other page created in the same context. It does not see downloads from a separate browser context. In Playwright Test, each test normally receives its own isolated context, so the broader event still stays within that test unless the suite deliberately shares or creates additional contexts.
The event delivers a Download object. download.page() identifies the page that initiated it. download.url() reports the download URL, and download.suggestedFilename() reports the browser's suggested filename. Those values are useful for correlation, but none proves the payload is correct. A login HTML response can have a plausible URL. A report service can send yesterday's data under today's filename. The final oracle has to inspect a product result or the saved content.
Timing has another boundary. Playwright emits the event when the download starts. Completion happens later. download.failure() waits for completion and returns null when no download error occurred, otherwise it returns an error string. download.saveAs() copies the file to a chosen path and waits for the download to finish when necessary. Merely receiving the event proves that the browser classified a response as an attachment and began handling it. It does not prove all bytes arrived.
Temporary file ownership follows the context. Playwright's documentation states that downloaded files belonging to a browser context are deleted when that context closes. A test that lets its fixture finish before awaiting an async listener can lose the file during teardown. That often produces a confusing pattern: the trace shows the click, the event handler logged a filename, and a later copy operation cannot find usable content. Keep completion and persistence in the main awaited control flow.
There are three common ownership choices. Use page.waitForEvent('download') when the action and source page are known. Use context.waitForEvent('download') when the action may hand work to another page, or when a helper needs to catch a file from any page in a bounded context. Use context.on('download') only for genuinely open-ended monitoring, and track every async job created by that listener. A permanent listener is not a substitute for an assertion.
The broadest option has a correlation cost. If an autosave export, another tab, or a second user action can start a download first, an unfiltered context wait may resolve with the wrong file. Add a predicate that checks download.page(), the expected filename, the URL, or a combination tied to the product flow. Prefer the source page because filenames and URLs can be shared by repeated exports. Keep the predicate cheap and synchronous; content validation belongs after the event is captured.
Version checks are part of diagnosis, not trivia. In Playwright 1.59 and earlier, BrowserContext did not mirror the page download lifecycle event. Code copied from a current example into an older suite cannot gain that behavior through a longer timeout. Use the page event on the old version or upgrade Playwright and its browser binaries together. Do not silence a type error with a cast that promises an event the runtime does not emit.
Capture a known download without losing the race
Register the waiter before the click. The click can start the response and dispatch the event before its promise resolves. Writing the click first and the event wait second leaves a race window. It may pass against a slow development server and fail when a cached CI response is fast. The familiar Playwright pattern is deliberate: create the event promise without awaiting it, perform the trigger, then await the promise.
A popup workflow adds one more step. Capture the popup before the action that opens it, then wait for the download on the context with a predicate tied to that popup. The test below verifies source page, suggested name, completion status, and CSV contents. Each assertion can fail for a real product change. A report opening in the wrong tab, a filename regression, a truncated transfer, or a header-only CSV produces a different failure.
import { readFile, rm } from 'node:fs/promises';
import { expect, test } from '@playwright/test';
test('saves the account export started by the report popup', async ({
context,
page,
}, testInfo) => {
await page.goto('/accounts/acme');
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Open report center' }).click();
const reportPage = await popupPromise;
await reportPage.waitForLoadState('domcontentloaded');
const downloadPromise = context.waitForEvent('download', {
predicate: (download) => download.page() === reportPage,
timeout: 10_000,
});
await reportPage.getByRole('button', { name: 'Export account CSV' }).click();
const download = await downloadPromise;
expect(download.page()).toBe(reportPage);
expect(download.suggestedFilename()).toBe('acme-account.csv');
expect(await download.failure()).toBeNull();
const destination = testInfo.outputPath('account-export.csv');
await download.saveAs(destination);
const csv = await readFile(destination, 'utf8');
expect(csv.split(/\r?\n/, 1)[0]).toBe('account_id,status,balance');
expect(csv).toContain('acme,active,');
await rm(destination);
});The explicit timeout is a diagnostic decision, not a performance target. It bounds how long this event boundary can consume before the test reports that no matching download started. A longer value may be appropriate for report generation, but generation time and event registration are separate concerns. If the application shows a server-side job in progress before enabling the export, wait on that user-visible readiness state first, then start the short event wait around the actual download click.
The CSV assertions should match the real contract. Checking only that the file contains a comma creates a test that almost any CSV can pass. Checking an account identifier and required headers can fail when the service returns the wrong tenant or omits a field. If balances are volatile, do not hard-code a value merely to make the assertion look precise. Seed a known account or validate stable relationships such as row identity and schema.
Filename assertions have a narrower purpose. A filename matters when users rely on it to identify or import the export. If the server intentionally includes a timestamp, match the stable pattern and verify the date through controlled clock or response metadata only when the product owns that promise. Do not use the filename as a proxy for payload accuracy. A correct name can cover a stale report.
Use a test-owned destination. testInfo.outputPath() places the copied file under the current test's output area, which avoids workers writing the same repository path. A constant path such as /tmp/report.csv creates collisions when tests run in parallel or retry. It can also leave one attempt reading a file from an earlier attempt. The destination name can be stable inside the isolated output area even when the browser's suggestion varies.
The call to failure() waits for completion before the copy in this example. saveAs() would wait too, but checking failure first yields a direct assertion when the transfer fails. Teams may reverse the order if they want saveAs() to be the failing operation. What matters is that one awaited completion operation remains inside the test before the context fixture closes.
Correlate downloads when several pages are active
A context-level event earns its place when several pages participate in one scenario. An operations console might keep billing and audit reports in separate tabs. A desktop-style application may open export tools in popups. An administrator might trigger two independent archives and expect both. The context listener removes the need to guess which page object owns the event, but the test must still pair each event with its trigger.
Start both filtered waits before either click if the actions can overlap. Filtering only by .csv is not enough when both pages produce CSV files. Filtering by the source page creates two non-overlapping event promises. After capture, inspect each download independently. This prevents the first fast export from satisfying both conceptual expectations, even though each event promise resolves at most once.
import { readFile, rm } from 'node:fs/promises';
import { expect, test } from '@playwright/test';
test('keeps simultaneous exports paired with their source pages', async ({
context,
}) => {
const billingPage = await context.newPage();
const auditPage = await context.newPage();
await Promise.all([
billingPage.goto('/admin/billing'),
auditPage.goto('/admin/audit'),
]);
const billingDownloadPromise = context.waitForEvent(
'download',
(download) => download.page() === billingPage,
);
const auditDownloadPromise = context.waitForEvent(
'download',
(download) => download.page() === auditPage,
);
await Promise.all([
billingPage.getByRole('button', { name: 'Export invoices' }).click(),
auditPage.getByRole('button', { name: 'Export audit log' }).click(),
]);
const [billingDownload, auditDownload] = await Promise.all([
billingDownloadPromise,
auditDownloadPromise,
]);
const invoicesPath = test.info().outputPath('invoices.csv');
const auditPath = test.info().outputPath('audit-log.csv');
await Promise.all([
billingDownload.saveAs(invoicesPath),
auditDownload.saveAs(auditPath),
]);
const [invoices, auditLog] = await Promise.all([
readFile(invoicesPath, 'utf8'),
readFile(auditPath, 'utf8'),
]);
expect(billingDownload.suggestedFilename()).toMatch(/^invoices-.+\.csv$/);
expect(auditDownload.suggestedFilename()).toMatch(/^audit-.+\.csv$/);
expect(invoices).toContain('invoice_id,customer_id,total');
expect(auditLog).toContain('event_id,actor,action');
await Promise.all([rm(invoicesPath), rm(auditPath)]);
});That code uses two known events, not an open-ended listener. The promises are awaited, their saved paths are distinct, and their content checks express different schemas. A broken audit endpoint returning invoice data cannot pass merely because both files exist. A page-routing regression also fails at the source-page predicate rather than being mislabeled as bad CSV.
Be careful with predicates that are too strict. If the filename itself is the behavior under test, filtering the event by the expected filename can turn a useful mismatch into a timeout. Filter by source page or a stable URL, capture the download, then assert the filename separately. The report will say that the actual name was wrong instead of claiming no event occurred. Use a filename predicate only when it is needed to distinguish several downloads from the same page.
Repeated downloads from one page need another correlation key. The URL may include a report identifier, or the product may guarantee distinct suggested names. If there is no stable event metadata, serialize the actions: register a waiter, trigger the first export, complete and validate it, then repeat for the second. Serialization costs time but removes ambiguity. Parallelism is not a virtue when the observable events cannot be matched reliably.
A persistent context.on('download') handler is suitable for telemetry or for a workflow in which downloads have no predictable trigger. The official downloads guide warns that event handling forks control flow and the scenario may end while the file is still downloading. If you choose that model, store every processing promise in a set, remove it only when settled, and await the set before the test ends. Also remove or scope the listener if the context outlives the case. Otherwise one test's handler can process a later test's download in suites that reuse context intentionally.
Do not turn the event log into a data leak. Download URLs can contain query tokens, filenames can contain customer names, and retained payloads may contain personal or financial data. Attach a sanitized manifest by default. Save a full file only when a content assertion needs it and policy permits it, then remove a successful working copy after parsing. Better debugging evidence is not automatically more data.
Diagnose an event that arrived but never completed
The first diagnostic split is simple: did a matching event arrive? If it did not, investigate scope, ordering, version, and response classification. If it did, investigate completion, payload, and teardown. Mixing those branches leads teams to increase a wait timeout for a transfer that already started, or to inspect CSV parsing when the browser never classified the response as a download.
No event after the click can mean the server returned an inline document rather than an attachment. It can mean authentication redirected the tab to sign-in, the endpoint rendered an error page, the popup was blocked by application logic, or the wait belongs to another context. Look at the trace action immediately after the click. Check whether the page URL or DOM changed. Inspect the relevant network entry and server log for status and response headers when available. The decisive evidence is that the browser followed a navigation or rendered content instead of emitting a matching attachment event.
A late waiter has a distinctive chronology. The trace shows a successful click and network activity, while the code begins waitForEvent() only afterward. The file may even exist temporarily. This is not intermittent browser slowness. Move promise creation above the trigger. Do not add a retry, because a slower retry can pass and preserve the race.
An event that arrives followed by a non-null failure() is a transfer failure. Capture a small metadata record before asserting so the report retains sanitized source and download routes, the file extension, and the failure string. Do not invent a canonical failure message. Browser engines and underlying errors can differ. Assert null for success, and attach the actual string when it is not null.
import { Buffer } from 'node:buffer';
import { extname } from 'node:path';
import { expect, test } from '@playwright/test';
test('records download completion evidence before asserting success', async ({
context,
page,
}, testInfo) => {
await page.goto('/reports/security');
const downloadPromise = context.waitForEvent('download', {
predicate: (download) => download.page() === page,
timeout: 15_000,
});
await page.getByRole('button', { name: 'Download security report' }).click();
const download = await downloadPromise;
const failure = await download.failure();
const summarizeUrl = (value: string) => {
const url = new URL(value);
const isHttp = url.protocol === 'http:' || url.protocol === 'https:';
return {
protocol: url.protocol,
origin: isHttp ? url.origin : null,
pathDepth: isHttp
? url.pathname.split('/').filter(Boolean).length
: 0,
};
};
await testInfo.attach('download-metadata', {
body: Buffer.from(
JSON.stringify(
{
sourcePage: summarizeUrl(download.page().url()),
downloadUrl: summarizeUrl(download.url()),
suggestedExtension: extname(download.suggestedFilename()).toLowerCase(),
failure,
},
null,
2,
),
),
contentType: 'application/json',
});
expect(failure).toBeNull();
});The metadata attachment is evidence, not the oracle. The assertion can fail when the transfer fails. A hard-coded object checked against itself could not. For a complete report test, add a PDF parser or another supported document-level check outside this helper. A non-empty file is not always sufficient, and an empty file is not always invalid. Let the report contract decide.
download.path() has two important constraints. It waits for completion, and the API documentation says it throws when Playwright is connected remotely. It also returns a temporary path whose filename is a random GUID, not the suggested filename. Prefer saveAs() when the test needs a stable artifact or may run through a remote connection. Use suggestedFilename() for the user-facing name, not the basename of path().
Context closure creates another near-miss. The download succeeds, an async listener starts copying it, and the test function returns. Fixture teardown closes the context and deletes its temporary downloads. The copy then fails or produces no retained artifact. The fix is not a sleep in afterEach. Return or collect the promise and await it before the test finishes. Sleeps only widen the window and remain sensitive to file size and machine load.
Wrong content with a successful transfer is a product failure beyond the event. Parse CSV headers and stable identifiers, inspect archive entries, or validate the document using a format-aware library already approved by the project. Do not add a parser dependency solely for one vague assertion without weighing maintenance and security. An API call that exposes export job metadata may provide a cheaper stable oracle, paired with one browser test that verifies the download wiring.
Trace Viewer contributes chronology rather than file truth. Its Actions panel shows locators, action duration, source locations, logs, and before and after snapshots. Its Network panel can narrow requests to a selected time range. Use it to see whether a click navigated, whether a popup remained open, and when requests occurred. Keep download metadata and parsed content assertions in the test report because a screenshot cannot establish the saved bytes.
Migrate safely to the context-level event
Begin by recording the Playwright version resolved by the lockfile, not only the range in package.json. BrowserContext download support starts at 1.60. This repository resolves 1.61.1, so migration can use it without a compatibility wrapper. A shared library that supports older consumers should either keep the page-level path or publish an explicit minimum version. Runtime feature guesses and type casts make failures harder to understand.
Inventory current waits before replacing them. Page-level waits attached to a known export page are already good. Leave them alone. Look for helpers that wait on the opener while the action moves to a popup, listeners copied onto every page, or code that races across context.pages() hoping to find the owner. Those are candidates for one context wait with a source-page predicate.
Change one flow at a time and keep its business assertions intact. A mechanical migration that changes page to context can broaden the event without proving it is the right event. Add download.page() evidence and a predicate wherever another page can download concurrently. Run the case with an intentionally unrelated download if the application can produce one. The expected waiter should ignore that event and capture its own export.
Set download acceptance and failure artifacts explicitly in test configuration so local and CI runs share the same assumptions. This configuration uses APIs available in Playwright 1.61.1. It retains traces for failed attempts and keeps test-owned output under a known directory. The setting does not save downloads permanently; each test still calls saveAs() for evidence it needs after context teardown.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
outputDir: 'test-results',
timeout: 60_000,
retries: process.env.CI ? 1 : 0,
reporter: [
['line'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
],
use: {
acceptDownloads: true,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Browser coverage has a concrete cost. Export tests write files, parse content, and can put sustained load on report services. Run the core attachment behavior in every supported browser if response handling differs or browser support is contractual. Keep expensive report-generation matrices closer to the service. One browser journey per important wiring path plus API-level schema coverage often gives clearer failures than dozens of repeated downloads.
Retries need separate evidence. testInfo.outputPath() naturally gives the attempt a test-owned area, while a shared absolute path can let a retry read the first attempt's file. Retain the first failure trace and its sanitized metadata. A retry that passes can indicate a race in listener ordering, a slow report job, or shared test data. It should not overwrite the proof needed to diagnose the first attempt.
Plan cleanup around data, not temporary browser files. Context closure handles its temporary downloads. Your test must remove generated report jobs, seeded accounts, or server records through narrowly scoped teardown. Delete successful working copies after their content assertions, and let the CI artifact policy govern any failure evidence you intentionally retain. Never run broad cleanup against a common exports directory shared by workers.
During review, ask what each assertion can catch. download.page() catches source-tab mistakes. suggestedFilename() catches naming regressions. failure() catches transfer failure. Parsing the saved bytes catches wrong or incomplete product content. An event count alone catches only that some attachment started. Keeping those claims explicit prevents a migration from trading a precise page test for a broad but weak context listener.
Avoid the context listener when narrower evidence is better
Stay with page.waitForEvent('download') when a single known page owns the click and no handoff occurs. The narrower wait communicates intent and cannot consume another tab's event. BrowserContext is not a newer replacement that should be applied everywhere. It is an additional scope for multi-page and uncertain-source workflows.
Do not use either browser event to test report-generation rules exhaustively. If the risk is tax calculation, row filtering, locale formatting, or a million-record archive, exercise those rules through the report service or job API. Keep a small browser case to prove that the user action starts the right export and delivers usable content. This split reduces runtime and points failures at the responsible layer.
An inline PDF viewer is not necessarily a failed download. If the product intentionally renders a document in the tab, a download event is the wrong expected behavior. Assert the viewer URL, visible document state, or an explicit Save action according to the design. Forcing attachment headers in a test route would change the behavior under test and manufacture a pass.
Avoid a permanent context listener when every trigger is known. Open-ended handlers fork control flow, complicate teardown, and make correlation harder. A pair of targeted waits is often longer in code but much clearer in a failure report. Use a collector only when unpredictability is part of the real product, then expose an awaited completion method and a bounded expected count.
Do not save every successful production-like export as a CI artifact. The disk cost grows with browsers, workers, retries, and retention days. More importantly, exports may contain sensitive test data. Persist only what the assertion needs, attach sanitized metadata by default, and retain full payloads under an approved policy. Content can often be parsed and then discarded while the test report keeps a small schema result.
Skip filename assertions when the browser suggestion is not a user contract. Services may legitimately change generated names while preserving content and headers. Conversely, keep the assertion when downstream users import files by naming convention. The decision belongs to the workflow, not to the convenience of suggestedFilename().
Do not call an event a completed download. That shortcut is the source of many false greens. The event begins the observation, a completion API establishes transfer outcome, and a domain assertion establishes product correctness. If the defect is a popup whose export escapes a page-scoped waiter, BrowserContext is the right repair. If the defect is wrong rows in a successfully saved CSV, broadening the listener will not move the test one step closer to the cause.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official playwright.dev reference
playwright.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Can BrowserContext wait for a download in Playwright?
Yes, starting with Playwright 1.60, a browser context emits `download` for an attachment started by any page that belongs to it. Older projects should use a page-level download event or upgrade with the matching browser binaries.
Why does a context download wait time out even though the file appeared?
A timeout usually points to a late listener, the wrong browser context, or a response that navigated or rendered inline instead of becoming an attachment. Check the installed Playwright version and register the wait before the triggering click.
How do I know which tab started a Playwright download?
Call `download.page()` and compare the returned Page with the tab or popup expected by the scenario. Add a predicate to the context wait when several pages can start downloads at the same time.
Does receiving the download event mean the file completed successfully?
Not by itself. The event is emitted when the download starts; `download.failure()` waits for completion and returns `null` on success, while `saveAs()` also waits before copying the file.
Where does Playwright keep a downloaded file after the test?
Temporary downloads belong to their browser context and are deleted when that context closes. Save required evidence to a test-owned output path before teardown, and avoid retaining exports that contain unnecessary customer data.
RELATED GUIDES
Continue the learning route
GUIDE 01
Playwright Java JUnit BrowserContext Isolation Architecture
Playwright Java junit browser context architecture: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation.
GUIDE 02
20 Playwright Context, Page, Popup, and Frame Interview Scenarios
Solve 20 senior Playwright browser topology scenarios covering context isolation, multiple pages, popups, frames, permissions, events, and cleanup.
GUIDE 03
Force Browser Downloads with Selenium Manager
A practical guide to Selenium Manager force browser download, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 04
Race-Free Dialog and Browser Event Handling in Playwright
Handle alerts, confirms, prompts, beforeunload dialogs, and one-off browser events in Playwright without stalled actions, missed events, or leaked listeners.
GUIDE 05
Prevent Browser Downloads with Selenium Manager
A practical guide to Selenium Manager avoid browser download, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.