How to Fix Playwright Screenshots That Are Not Saved
A practical guide to missing Playwright screenshot files: paths, buffers, test artifacts, automatic capture, permissions, and reliable fixes.
Most missing Playwright screenshots have one of two causes: the call omitted path, so Playwright returned an in-memory buffer, or the path was written somewhere other than the directory you checked. Add an awaited path, log the resolved location, and use Playwright Test’s artifact APIs when the screenshot belongs to a test report.
1. Save a screenshot directly to a file
This is the smallest working example. The await matters: the browser must finish writing before the process exits.
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'networkidle' });
await page.screenshot({ path: 'artifacts/example.png' });
await browser.close();
Create the parent directory yourself when necessary. Playwright does not make every arbitrary parent directory for you.
import { mkdir } from 'node:fs/promises';
import { chromium } from 'playwright';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'artifacts/example.png', fullPage: true });
await browser.close();
2. Check whether you received a buffer instead of a file
page.screenshot() without path succeeds by returning image bytes. It does not create a disk file. Use the buffer for an upload, image processing step, or test attachment:
const screenshot = await page.screenshot();
console.log(Buffer.isBuffer(screenshot), screenshot.length);
To save that buffer yourself:
import { writeFile } from 'node:fs/promises';
const screenshot = await page.screenshot();
await writeFile('artifacts/from-buffer.png', screenshot);
This distinction explains a common symptom: the call returns normally, but searching the project finds no PNG.
3. Resolve the path you actually used
Relative paths are resolved from the process’s current working directory, not from the directory containing the test file. Print it while diagnosing:
import process from 'node:process';
import path from 'node:path';
console.log('working directory:', process.cwd());
console.log('resolved screenshot:', path.resolve('artifacts/page.png'));
await page.screenshot({ path: path.resolve('artifacts/page.png') });
An absolute path removes ambiguity during debugging. In CI, also inspect the job workspace and upload the resulting directory as a build artifact.
4. Understand the four Playwright screenshot destinations
| Goal | API | Who owns the destination? |
|---|---|---|
| Ordinary image file | page.screenshot({ path }) or locator.screenshot({ path }) |
Your supplied path |
| Visual regression baseline | expect(page).toHaveScreenshot() |
Playwright Test snapshot configuration |
| Per-test output | testInfo.outputPath('name.png') |
Playwright Test output directory |
| Reporter attachment | testInfo.attach() |
Reporter and test-runner artifact handling |
These mechanisms are related but interchangeable only when you deliberately copy or attach the bytes.
Visual snapshot assertions
toHaveScreenshot() compares against a managed baseline. Its named snapshot must remain inside the test’s snapshots directory. It is not an arbitrary output path.
import { test, expect } from '@playwright/test';
test('homepage visual snapshot', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveScreenshot('homepage.png', { fullPage: true });
});
If you expected a newly created file in artifacts/, use page.screenshot({ path: ... }) instead.
Test output and reporter attachments
import { test } from '@playwright/test';
test('attach a screenshot', async ({ page }, testInfo) => {
await page.goto('https://example.com');
const image = await page.screenshot();
await testInfo.attach('homepage', {
body: image,
contentType: 'image/png'
});
const outputFile = testInfo.outputPath('homepage.png');
await page.screenshot({ path: outputFile });
});
Use attachments when the report must display the image, and outputPath() when another CI step needs a predictable per-test file.
5. Configure automatic screenshots in Playwright Test
Automatic screenshot capture is configured in the Playwright Test runner and defaults to off. Supported modes include on, only-on-failure, and on-first-failure.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
screenshot: 'only-on-failure'
}
});
If this setting is off, a failed test will not automatically produce an image. You can still capture one explicitly in the test or in a hook.
6. Capture the right page, element, and state
Full page versus viewport
await page.screenshot({ path: 'artifacts/viewport.png' });
await page.screenshot({ path: 'artifacts/full-page.png', fullPage: true });
fullPage: true captures the full scrollable page. A normal screenshot captures the current viewport.
Locator screenshots
await page.locator('#invoice').screenshot({ path: 'artifacts/invoice.png' });
Wait for the locator to be visible and stable before capturing. If the selector matches nothing, the capture fails rather than producing the file you expected.
Wait for content and lazy images
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.locator('[data-loaded="true"]').waitFor();
await page.waitForTimeout(300);
await page.screenshot({ path: 'artifacts/ready.png', fullPage: true });
Prefer a meaningful selector or application-ready signal over a long fixed delay. Network idle can be unsuitable for pages with analytics, polling, or websocket traffic.
7. Troubleshooting checklist
| Symptom | Likely cause | Fix |
|---|---|---|
| No file, no error | path omitted |
Pass a path or write the returned buffer. |
| File exists elsewhere | Relative path resolved from another working directory | Log process.cwd() and use an absolute path while diagnosing. |
ENOENT |
Parent directory does not exist | Run mkdir(..., { recursive: true }) before capture. |
EACCES or permission denied |
Process cannot write to the selected directory | Choose a writable workspace or fix the CI/container permissions. |
| Test report has no image | Screenshot mode is off, or bytes were never attached |
Set screenshot: 'only-on-failure' or call testInfo.attach(). |
| Snapshot comparison looks in the wrong folder | Confusing toHaveScreenshot() with a direct file write |
Use the configured snapshots directory, or switch to page.screenshot({ path }). |
| Blank or incomplete image | Capture happened before content rendered | Wait for a selector, stable state, or a deliberate short delay. |
| Browser closes before writing | Missing await or process exits early |
Await the screenshot and close the browser only afterward. |
| Works locally, missing in CI | Artifact retention or workspace cleanup | Write under the job output directory and configure CI artifact upload. |
8. A diagnostic script you can run
import { mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { chromium } from 'playwright';
const target = path.resolve('artifacts/diagnostic.png');
console.log({ cwd: process.cwd(), target });
await mkdir(path.dirname(target), { recursive: true });
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await page.screenshot({ path: target });
const info = await stat(target);
console.log({ saved: target, bytes: info.size });
} finally {
await browser.close();
}
If this script reports a file, the remaining problem is usually test-runner configuration, CI artifact collection, or a different path in the original code.
9. Performance, reliability, and cost considerations
- Viewport screenshots are usually cheaper in time and disk space than full-page captures.
- Use a readiness selector instead of excessive sleeps; it reduces needless waiting while preserving correctness.
- Keep screenshot files out of source control and clean temporary output after the job.
- For visual tests, stabilize fonts, animations, clocks, and dynamic data before comparing pixels.
- When parallel workers write files, include a worker or test identifier in each filename to avoid collisions.
- In CI, treat screenshots as artifacts and verify retention settings; a successful write does not guarantee long-term availability.
- Playwright itself does not charge per screenshot. Your costs are compute, storage, CI minutes, and any external browser or screenshot service you add.
10. Or skip the browser setup
If you need an image or PDF from a URL without maintaining Playwright browsers, ScreenshotNeo provides a single GET request. See the ScreenshotNeo API documentation for options.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
ScreenshotNeo removes cookie banners, newsletter popups, and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
11. FAQ
Why does Playwright return bytes instead of saving PNG?
That is the expected behavior when path is omitted. Save the returned buffer or provide a path.
Does fullPage change where the file is saved?
No. It changes the captured region; the destination still comes from path.
Can I use the same path for parallel tests?
You can, but workers may overwrite one another. Generate unique names from the test title, worker index, or testInfo.outputPath().
Should I use a delay or network idle?
Use an application-specific readiness selector when possible. Network idle may never occur on pages with ongoing requests.
Why is a failed test missing an automatic screenshot?
Check the configured screenshot mode. The documented default is off; set only-on-failure or capture and attach the image yourself.


