Playwright Screenshot Is Blank on a Page with WebGL: Fix
Diagnose blank WebGL screenshots in Playwright by checking page readiness, headless mode, and GPU setup. Then make captures repeatable in CI.
A blank Playwright screenshot on a WebGL page does not, by itself, tell you what failed. The page may not have initialized or drawn its scene, or the browser’s headless and GPU setup may differ from the environment where the page works. First prove the app rendered before capture; then compare headed and headless runs, check which Chromium headless mode is in use, and inspect GPU and display support on the host.
Playwright’s screenshot API captures the rendered page; it cannot make an uninitialized WebGL scene draw. Use the sequence below to locate the failing layer before changing browser flags. See the official Playwright Page API.
1. Confirm the page and WebGL scene rendered
Do not use a fixed sleep as your only readiness check. A delay can pass while the scene is still waiting for data, shaders, or a render loop. Prefer an application-owned ready signal or an assertion about the canvas or scene that only becomes true after initialization.
This runnable TypeScript example logs browser errors, waits for an app-defined data-scene-ready="true" marker, and saves a screenshot. Adapt the selector to a state your app sets only after it has initialized and drawn. If the app has no such signal, add one or expose another meaningful render assertion.
import { test, expect } from '@playwright/test';
test('capture the rendered WebGL scene', async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
const failedRequests: string[] = [];
page.on('pageerror', error => pageErrors.push(error.message));
page.on('console', message => {
if (message.type() === 'error') consoleErrors.push(message.text());
});
page.on('requestfailed', request => {
failedRequests.push(`${request.url()}: ${request.failure()?.errorText ?? 'request failed'}`);
});
await page.goto('http://localhost:3000/scene', { waitUntil: 'domcontentloaded' });
const canvas = page.locator('canvas');
await expect(canvas).toBeVisible();
await expect(page.locator('[data-scene-ready="true"]')).toBeVisible({ timeout: 30000 });
// Replace this with an app-specific assertion if the ready marker can precede the first draw.
const diagnostics = await canvas.evaluate((element) => {
const canvas = element as HTMLCanvasElement;
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
return {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
hasWebGLContext: Boolean(gl),
contextLost: gl ? gl.isContextLost() : null,
};
});
console.log({ diagnostics, pageErrors, consoleErrors, failedRequests });
expect(diagnostics.width).toBeGreaterThan(0);
expect(diagnostics.height).toBeGreaterThan(0);
expect(diagnostics.hasWebGLContext).toBe(true);
expect(diagnostics.contextLost).toBe(false);
await page.screenshot({ path: 'webgl-scene.png', fullPage: true });
});
Canvas dimensions and a live context are useful checks, but do not prove nonblank pixels: an initialized context can still have no completed draw. For stronger evidence, have the application signal after its first successful render, or inspect a known scene element or pixel in an app-specific test. Check failed requests and errors before blaming screenshot capture.
2. Compare headed and headless with the same test
Run the same URL, browser build, viewport, data, and app state once headed and once headless. If only one result is blank, the difference narrows the investigation toward browser mode or the host graphics path. This is a diagnostic inference, not a guaranteed root cause; a race or environment-dependent application behavior can also produce different results.
import { test, expect } from '@playwright/test';
test('scene reaches its ready state before capture', async ({ page }) => {
await page.goto('http://localhost:3000/scene', { waitUntil: 'domcontentloaded' });
await expect(page.locator('[data-scene-ready="true"]')).toBeVisible({ timeout: 30000 });
await page.screenshot({ path: 'scene.png' });
});
# Run headed to compare with the default headless run
npx playwright test --project=chromium --headed
# Run headless (Playwright's default for test runs)
npx playwright test --project=chromium
Keep the capture conditions fixed during this comparison. Changing the viewport, data, timing, browser version, and headless mode at once makes the result hard to interpret. If headed mode is also blank, return to application readiness, page errors, failed requests, and WebGL context state.
3. Identify Playwright’s Chromium headless mode
Playwright’s default headless setup uses a separate Chromium headless shell. To try Chromium’s new headless mode, configure the project with channel: 'chromium'. These modes can behave differently; use the comparison to narrow the issue, not as a promise that one mode fixes every WebGL page. See Playwright’s browser documentation.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [{
name: 'chromium-new-headless',
use: {
...devices['Desktop Chrome'],
channel: 'chromium',
},
}],
});
Install the browser build required by the Playwright release in your project using its documented browser-install workflow. Record the Playwright package version, installed browser version, operating system, and project configuration alongside the result. Do not assume that a locally installed Chrome build and Playwright’s bundled browsers have identical behavior.
4. Check Linux GPU and display support
Headless does not mean GPU rendering is always available or configured the same way across hosts. Chromium’s guidance says --enable-gpu prevents forced software rendering. On Linux, its default OpenGL autodetection requires an X11 server and DISPLAY. The guidance also notes that forcing Vulkan with --use-angle=vulkan has worked in some Linux configurations; it is not a universal fix. Read the Chromium Project’s headless GPU guidance.
Inspect the actual CI or container running the browser: whether a display server is present when the chosen path needs one, whether DISPLAY is set, and whether the GPU and driver are available to the process. Change one graphics setting at a time and rerun the same capture. Verify that the context is created and the app draws; do not pile on unrelated launch flags.
Playwright accepts Chromium launch options through its browser configuration, but GPU flags are environment-sensitive. Confirm the supported launch-options interface and exact installed Chromium release before adding flags to a project. The Chromium guidance describes command-line behavior; it does not establish that a particular flag fixes every Playwright setup.
5. Make visual captures repeatable
After finding a working configuration, keep visual comparisons in a consistent environment. Playwright notes that rendering can vary with operating system, browser version, settings, hardware, power source, and headless mode. Generate and compare baselines in the same environment where possible. Its visual comparisons guide documents screenshot assertions and baseline considerations.
import { test, expect } from '@playwright/test';
test('WebGL scene matches its visual baseline', async ({ page }) => {
await page.goto('http://localhost:3000/scene', { waitUntil: 'domcontentloaded' });
await expect(page.locator('[data-scene-ready="true"]')).toBeVisible({ timeout: 30000 });
await expect(page).toHaveScreenshot('webgl-scene.png', {
fullPage: true,
animations: 'disabled',
});
});
Use the same Playwright and browser build, OS image, viewport, test data, and graphics setup for baseline creation and comparison. Stabilize application state before capturing. Disabling animations helps with animation variability; it does not make WebGL initialize or prove the scene rendered.
6. Troubleshoot by symptom
| Symptom | Likely area to inspect | Next step |
|---|---|---|
| Canvas is missing or has zero dimensions | App initialization, layout, or failed page resources | Check console and request failures; wait for the app’s real ready state and confirm the canvas has nonzero dimensions. |
| Canvas exists, but no WebGL context is created | Browser or host graphics support, context creation errors, or app setup | Log context state and page errors; compare headed and headless on the same host and check GPU/display configuration. |
| Context exists, but the scene is blank | Scene has not drawn, shader or resource initialization failed, or rendering is app-state dependent | Wait for an app-owned first-render signal; inspect console errors and failed resource requests. Context existence alone is not proof of a draw. |
| Headed capture works, default headless is blank | Headless shell versus host GPU path | Try channel: 'chromium' as a controlled mode comparison, then inspect host graphics support. |
| One Linux CI worker is blank while another works | Different OS image, drivers, display setup, browser build, or hardware | Compare environment details and run the visual test in a consistent image and configuration. |
| Result changes after a fixed timeout | Readiness race | Replace the sleep-only wait with an app-defined ready or first-render condition, plus a bounded timeout. |
7. Performance, reliability, and cost
GPU rendering may be faster for graphics-heavy scenes, but whether it is available depends on the host and browser path. The cited Chromium guidance does not provide a universal performance figure or guarantee that enabling a GPU will improve a particular capture. Measure in the environment that will run the job and preserve that environment for visual baselines.
For reliability, make readiness explicit, keep browser builds and host configuration consistent, and log console errors, page errors, and failed requests when captures fail. A browser-mode experiment helps isolate a variable; it does not remove application timing or graphics dependencies.
Playwright screenshots run in your browser environment, so cost depends on your own CI or compute setup. The documentation cited here does not set a price for running those jobs. If you need a hosted screenshot API for ordinary web pages, ScreenshotNeo’s stated plans are free for 1,000 shots per month, then Starter at $5 for 3,000, Growth at $15 for 15,000, Pro at $39 for 60,000, Scale at $99 for 250,000, and Business at $249 for 1,000,000; yearly billing gives two months free. Every feature is available on every plan. WebGL rendering behavior is not specified in the ScreenshotNeo facts for this article, so use the Playwright diagnostics above when you need to validate a WebGL scene.
Or skip the browser setup
For a website screenshot API, ScreenshotNeo takes a URL and returns an image or PDF. Its API removes known cookie and consent banners, newsletter popups, and chat widgets before the shot; each cleanup step can be turned off. Bot checks and CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and the response includes page-verdict and billing headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for AI agents. These facts describe the service; they do not establish that it can render every WebGL page.
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,
)
r.raise_for_status()
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}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', Buffer.from(await res.arrayBuffer())));
Replace the example URL with the page you want to capture. For parameters and response details, see the ScreenshotNeo API documentation. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed; an MCP server lets AI agents take screenshots; and 1,000 screenshots a month are free with no card, with paid plans starting at $5 for 3,000. Sign up for 1,000 free screenshots a month, with no card required.
FAQ
Does a nonzero canvas size prove the screenshot should contain the scene?
No. It only shows that the canvas has dimensions. Confirm an app-owned first-render state or another application-specific visual condition.
Should I always switch to channel: 'chromium'?
No. It selects a different headless mode for comparison. Keep the mode that is appropriate for your test environment and verify its output there.
Does --enable-gpu fix every blank WebGL screenshot?
No. Chromium documents conditions for GPU use and Linux OpenGL detection, but the cause depends on the host and application. Diagnose the context and rendering path first.
Can visual baselines be shared across different operating systems?
They can be compared, but rendering differences may cause changes. Playwright recommends controlling the environment used to create and compare screenshot baselines.


