June 12, 2026
Playwright vs Selenium for Browser Downloads in Headless CI: What Actually Breaks First?
A practical comparison of Playwright vs Selenium browser downloads in headless CI, covering prompts, file persistence, permissions, artifacts, and the failure modes that break first.
When a test downloads a file in a browser, the problem is rarely the download itself. The real issues show up around it: a prompt that never appears in headless mode, a file that lands in a different directory than you expected, a permissions error inside a container, or a CI artifact that gets lost before anyone can inspect it. That is why Playwright vs Selenium browser downloads in headless CI is not a theoretical comparison. It is a comparison of how each tool behaves when the browser, the filesystem, and the pipeline all have opinions.
For teams doing headless browser file download testing, the differences are easy to miss until the first flaky build. Playwright usually gives you a cleaner path for setup and file capture. Selenium can absolutely do the job, but it tends to expose more of the underlying browser and grid complexity, especially when you need deterministic file persistence in CI. If you want to avoid re-learning the same download debugging on every pipeline, that matters.
What makes downloads break in headless CI
Downloads fail for boring reasons, which is exactly why they are so annoying. In a local desktop run, the browser may silently save a file to your Downloads folder and the test passes. In CI, the same flow can fail because:
- the browser runs headless and never shows a native prompt,
- the default download path is not writable,
- the container filesystem is ephemeral,
- the browser process runs as a different user,
- the grid node and the test runner do not share storage,
- the artifact gets produced but never uploaded,
- the application opens the file in a new tab instead of triggering a download,
- the browser blocks the download because the action was not tied to a user gesture.
That is why this topic is really about three layers at once:
- browser behavior,
- framework API design,
- CI artifact handling.
A framework can make one layer pleasant and still leave you to solve the other two manually. That is where most teams discover whether they are maintaining a test suite, or a download infrastructure project.
The first thing that breaks: prompt handling
The first breakage is usually the simplest one, and also the most misleading. Someone writes a test that clicks a button and expects a file save dialog. In headless mode, there is no operating system dialog to interact with. The browser is often configured to auto-download, or it suppresses prompt UI entirely.
Playwright
Playwright has first-class download handling. You usually wait for the download event, then save the file to a controlled location.
import { test, expect } from '@playwright/test';
import fs from 'fs';
test('downloads report', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise; const path = await download.path(); expect(path).toBeTruthy();
const suggested = download.suggestedFilename();
await download.saveAs(./artifacts/${suggested});
expect(fs.existsSync(./artifacts/${suggested})).toBe(true);
});
The practical advantage is that Playwright models downloads as a browser event rather than a guessed side effect. You know when the file exists, and you can save it deterministically.
Selenium
With Selenium, the browser can download files in headless mode, but the path is less standardized and more dependent on browser configuration. You often need to set browser profile preferences or command-line flags, then inspect the filesystem after the click.
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
import os
options = Options() options.add_argument(‘–headless=new’) options.add_experimental_option(‘prefs’, { ‘download.default_directory’: ‘/tmp/downloads’, ‘download.prompt_for_download’: False, ‘safebrowsing.enabled’: True, })
driver = webdriver.Chrome(options=options) driver.get(‘https://example.com/reports’) driver.find_element(By.CSS_SELECTOR, ‘button.export’).click()
This works, but it puts more burden on the runner, the browser version, and the execution environment. If the directory does not exist or is not writable, the test fails somewhere else than the click line, which makes debugging slower.
The first failure is rarely the click. It is usually the assumption that a browser download behaves the same in a local desktop session, a Docker container, and a remote grid node.
File persistence is where CI gets unforgiving
After the prompt issue, the next thing that breaks is persistence. A download might succeed in the browser but still disappear from your pipeline.
Common persistence problems
- The file lands in a temp directory that gets cleaned up when the job finishes.
- The test writes to a path inside the container, but the CI artifact step looks at the host workspace.
- Multiple tests reuse the same filename and overwrite each other.
- Parallel jobs write into the same directory and race.
- A remote browser node stores the file locally, but the runner cannot access it.
Playwright download persistence
Playwright helps because it gives you a file handle and a save step. You can name files based on the test, avoid collisions, and copy them into your artifact directory.
typescript
const download = await downloadPromise;
await download.saveAs(`test-output/${Date.now()}-${download.suggestedFilename()}`);
That is a small feature, but it changes the shape of the problem. You do not have to infer where the browser saved the file. You decide where it goes after the browser produces it.
Selenium download persistence
In Selenium, persistence is mostly a browser profile problem plus a filesystem problem. That means you must reason about Chrome preferences, Firefox profiles, container mounts, and CI permissions. It is manageable, but the burden is on the team to keep all layers aligned.
If you are using a Selenium Grid, the issue becomes sharper. The browser runs somewhere else, which means the file may exist on the node rather than the machine that runs assertions or uploads artifacts. At that point, your download test is no longer a simple browser test, it is a distributed file transfer problem.
Permission errors are the hidden tax
Headless CI often runs in a locked-down environment. That is where download-related failures become noisy.
Typical causes:
- the browser user cannot write to the target folder,
- the folder path is not mounted into the container,
- the CI image does not include required OS libraries or browser dependencies,
- security policies block writes outside workspace directories,
- the test runner runs as root locally but as a non-root user in CI.
Playwright generally reduces the amount of browser setup needed, but it does not eliminate permissions problems. You still need a writable directory and a sane artifact strategy. The difference is that Playwright surfaces download behavior in a way that makes the failure easier to locate.
Selenium tends to be more sensitive to browser setup variance because your configuration lives at the browser capability layer. Small differences between local Chrome, CI Chrome, and grid Chrome can change whether a file is saved at all.
What about real-world file assertions?
A download test is not complete just because a file exists. Teams usually need to check one or more of these:
- the file name is correct,
- the extension matches the format,
- the file is not empty,
- the headers or sheet names are correct,
- the content includes the expected record,
- the export is generated after filtering or search.
That means the browser step is only half of the test. The rest is file inspection.
Example: validating a CSV export in Playwright
import fs from 'fs';
import parse from 'csv-parse/sync';
test('exports filtered rows', async ({ page }) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
const filePath = await download.path(); if (!filePath) throw new Error(‘Download file path missing’);
const csv = fs.readFileSync(filePath, ‘utf-8’); const rows = parse.parse(csv, { columns: true }); expect(rows.length).toBeGreaterThan(0); });
In Selenium, the same pattern usually means you wait for a file to appear in a known folder, then inspect it with whatever language tools your stack uses. That is fine, but the waiting logic often becomes custom utility code that every team re-invents slightly differently.
Headless browser file download testing in containers
Docker changes the conversation. Inside a container, there is no desktop session, no real downloads folder, and often no persistent disk unless you explicitly mount one.
Here is what teams typically need in CI for both frameworks:
- a writable volume mounted to a predictable path,
- browser downloads pointed at that path,
- cleanup between tests,
- artifact upload after the run,
- stable filenames per test or per retry,
- enough disk space for large downloads.
A GitHub Actions job might look like this at a high level:
name: e2e
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx playwright test
- uses: actions/upload-artifact@v4
with:
name: download-artifacts
path: test-output/
This is the part many teams underestimate. The framework is not the whole solution. Your CI job has to preserve the file and upload it somewhere useful. If it does not, download validation becomes impossible to debug after the fact.
Where Selenium still fits well
It would be inaccurate to say Selenium cannot handle downloads. It can, and many teams already have the infrastructure. Selenium is a mature option when:
- your organization already uses it broadly,
- you have browser grids and artifact storage in place,
- you need the same approach across multiple languages,
- you have legacy tests that already manage browser preferences reliably.
The downside is the amount of setup overhead. For a small export test, the effort can feel disproportionate. You are not just testing an export button, you are tuning browser profiles, disk paths, grid capabilities, and CI artifacts.
If your team is already invested in Selenium, the question is not whether it can work. It is whether the download-specific glue code is worth owning long term.
Where Playwright usually wins on download tests
Playwright generally has the edge when the goal is to write stable, debuggable download tests quickly.
It tends to win on:
- explicit download events,
- easier file save handling,
- reduced browser configuration noise,
- strong fit with modern CI workflows,
- simpler test isolation in parallel runs.
That does not make it perfect. You still have to manage artifacts, inspect content, and design good assertions. But Playwright gives you fewer layers to debug before you reach the actual failure.
For teams comparing Playwright downloads with Selenium downloads, this is often the deciding factor. Not raw capability, but how many things can go wrong before you get a file on disk.
The browser-specific edge cases that surprise people
A few failure modes show up repeatedly:
1. The app opens a blob URL instead of a normal download
Some applications generate a file in memory and open it with a blob URL. In Playwright, you can still observe the download or track the response depending on implementation. In Selenium, you often need to inspect network behavior or adapt browser settings.
2. The filename changes dynamically
Reports often include timestamps or user IDs in the filename. Tests should not hardcode the entire name if the application does not guarantee it. Instead, verify the prefix or extension, then inspect the actual saved file name.
3. Parallel jobs clash
If every test writes export.csv, one job can overwrite another. Use per-test folders or unique names, no matter which framework you choose.
4. Large downloads time out
A file that takes 30 seconds to generate can look like a test failure if you only wait 5 seconds for the filesystem to update. Make the wait explicit and align it with the business process, not just the click.
5. The download is blocked by an authentication redirect
If the file endpoint requires a session that expires mid-test, the browser may download HTML error content instead of a CSV or PDF. This is why content validation matters, not just file existence.
Where Endtest changes the equation
If your team wants to reduce download-related setup and artifact-debugging overhead, Endtest is worth a serious look. It is an agentic AI Test automation platform with low-code and no-code workflows, so you are not spending as much time wiring browser preferences, file paths, and CI plumbing by hand.
That matters specifically for downloads because the pain is usually spread across creation, execution, and artifact collection. Endtest’s AI Test Creation Agent creates editable Endtest steps inside the platform, so teams can focus on the business flow and the expected file outcome instead of building a download harness first. For organizations still living in Selenium-heavy stacks, the migration path from Selenium can also reduce the amount of rework needed to get legacy coverage into a more managed workflow.
The useful distinction is this, code-based tools give you maximum control, but they also ask you to own every detail of browser configuration, file persistence, and CI cleanup. Endtest is positioned to take a lot of that operational burden off the team, which can be especially attractive when downloads are just one part of a broader test suite.
Decision criteria for your team
If you are choosing a tool specifically for download-heavy CI coverage, use these questions:
Choose Playwright if:
- you want direct control over download events,
- you need reliable file saving in headless runs,
- you are building a modern TypeScript or Python test stack,
- your team values simpler artifact capture and test isolation,
- you want fewer browser configuration edge cases.
Choose Selenium if:
- your organization already has a Selenium ecosystem,
- you need cross-language consistency with existing infrastructure,
- your browser grid and artifact handling are already mature,
- you are extending an older suite rather than starting fresh.
Consider Endtest if:
- you want to reduce setup and maintenance overhead,
- your QA or product team needs to contribute without writing code,
- you want a managed platform rather than another framework to wire into CI,
- download flows are just one of many browser workflows that need to be maintained.
Practical recommendation
For new projects, Playwright is usually the cleaner default for headless browser file download testing. The download event model is easier to reason about, the test code is more direct, and the CI failure surface is smaller.
For existing Selenium teams, it is not worth rewriting everything just because downloads are annoying. Instead, isolate the pain. Standardize your download directories, make filenames unique, and centralize file validation and artifact upload logic.
If the real issue is not the framework but the operational load of maintaining browser automation, then a managed platform like Endtest can be the better fit. It removes a lot of the low-value work around setup, file persistence, and artifact debugging, which is exactly where download tests consume time.
In download testing, the best tool is the one that gets you to a real file on disk, with the least amount of infrastructure drama.
Final takeaway
The first thing that breaks in headless CI is usually not the download button. It is the assumption that download behavior is portable across local, container, and grid environments. Playwright tends to break less often because it treats downloads as a first-class event. Selenium can work well, but it usually requires more browser-specific setup and more filesystem discipline. Endtest becomes interesting when you want to cut out the setup churn altogether and keep the team focused on validating the file, not debugging the pipeline that produced it.
If your export tests are flaky, start by checking where the file is supposed to land, who can write there, and how the artifact is uploaded. Those three details solve more CI download failures than any retry loop ever will.