WebGL in Headless Chrome: GPU, SwiftShader, Blank Canvas

Whether WebGL works in headless Chrome depends on the binary, GPU and flags. Measured renderer strings per setup, and code to probe and capture 3D pages.

By Simon Chiu · Updated

WebGL in headless Chrome is the ability of a browser with no window to create a WebGL context and draw 3D graphics into a canvas, which depends on whether that browser can reach a GPU or falls back to SwiftShader, Chrome's software (CPU) implementation of the GPU.

Maps, 3D product viewers, charts built on WebGL and many landing-page hero effects need it. When it is missing, the page does not crash: it draws an empty canvas or a "your browser does not support WebGL" message, and your screenshot quietly shows that. This entry is part of the screenshot glossary.

What it means in plain terms

A WebGL page asks the browser for a drawing context with canvas.getContext('webgl') or 'webgl2'. Chrome passes those calls through ANGLE, a translation layer, to a real graphics API. There are three possible backends:

  1. A hardware GPU (Metal on macOS, Vulkan or OpenGL on Linux, Direct3D on Windows).
  2. SwiftShader, which runs the same work on the CPU. It is slower, but its output is stable across machines.
  3. Nothing. getContext returns null.

Servers usually have no GPU, so a headless capture on Linux depends on option 2. Chrome no longer falls back to SwiftShader for WebGL on its own in every configuration; the --enable-unsafe-swiftshader flag opts in.

flowchart TD
  A["canvas.getContext('webgl')"] --> B{GPU process can use a GPU?}
  B -- yes --> G["ANGLE → hardware GPU"]
  B -- no --> C{SwiftShader allowed?<br/>headless shell, or --enable-unsafe-swiftshader}
  C -- yes --> S["ANGLE → SwiftShader on the CPU"]
  C -- no --> N["null: no WebGL, empty canvas"]

Measured: the renderer each setup gives you

We asked each configuration for a WebGL context and read the unmasked renderer string through the WEBGL_debug_renderer_info extension.

Launch WebGL / WebGL 2 renderer
Puppeteer, new headless (default) ANGLE Metal, Apple M1 Pro (hardware GPU)
Puppeteer, headless: 'shell' ANGLE Vulkan, SwiftShader
Puppeteer, headless: 'shell' + --disable-gpu ANGLE Vulkan, SwiftShader
Puppeteer, new headless + --disable-gpu none (getContext returned null)
New headless + --disable-gpu --enable-unsafe-swiftshader ANGLE Vulkan, SwiftShader
New headless + --use-angle=swiftshader --enable-unsafe-swiftshader ANGLE Vulkan, SwiftShader
Playwright chromium.launch() (headless shell) ANGLE Vulkan, SwiftShader (also with --disable-gpu)
Playwright channel: 'chromium' (new headless) ANGLE Metal, Apple M1 Pro

Method: 2026-10-15, Apple M1 Pro on macOS 27.0.1, Node 22.18.0, [email protected] with Chrome for Testing 155.0.8059.79, [email protected] with Chromium 156.0.8078.4. One probe per row; the renderer string is deterministic. A Linux server without a GPU would show SwiftShader or nothing in the hardware rows, so run the same probe on your own machines.

The row to remember is the fourth one. --disable-gpu appears in many copied Docker examples. With new headless mode, it removes WebGL completely.

Measured: what the capture actually contains

We drew one frame (a solid green clear colour) into a 400×200 WebGL canvas and read the centre pixel in two ways.

Setup WebGL context page.screenshot() centre canvas.toDataURL() centre
New headless, default yes rgb(0, 153, 77), the scene rgb(0, 0, 0), empty
Same, preserveDrawingBuffer: true yes rgb(0, 153, 77) rgb(0, 153, 77)
New headless, --disable-gpu no rgb(255, 255, 255), page background rgb(0, 0, 0), empty

A browser screenshot reads the composited page, so it shows the scene. Reading the canvas from page code after the frame was shown returns cleared pixels unless the context was created with preserveDrawingBuffer: true. That is a WebGL rule, not a headless bug.

How to set it

Puppeteer

import puppeteer from 'puppeteer';

const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch({
  // Software WebGL where no GPU exists. Only for pages you trust (see below).
  args: ['--enable-unsafe-swiftshader'],
});
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 1280, height: 800 });
  await page.goto(url, { waitUntil: 'load' });
  const renderer = await page.evaluate(() => {
    const gl = document.createElement('canvas').getContext('webgl');
    if (!gl) return null;
    const info = gl.getExtension('WEBGL_debug_renderer_info');
    return info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
  });
  if (!renderer) throw new Error('WebGL is not available in this browser');
  console.log('WebGL renderer:', renderer);
  await page.screenshot({ path: 'webgl.png' });
} finally {
  await browser.close();
}

Playwright

import { chromium } from 'playwright';

const browser = await chromium.launch({ args: ['--enable-unsafe-swiftshader'] });
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto(process.argv[2] ?? 'https://example.com');
  const hasWebGL = await page.evaluate(() => !!document.createElement('canvas').getContext('webgl2'));
  if (!hasWebGL) throw new Error('WebGL 2 is not available');
  await page.screenshot({ path: 'webgl-pw.png' });
  console.log('wrote webgl-pw.png');
} finally {
  await browser.close();
}

Tested on 2026-10-15 with the versions in the method note above.

RenderScreenshot

There is no GPU or WebGL parameter in the API. To learn what the renderer gives a WebGL page, send the probe itself as HTML input and read the renderer string in the image:

import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const probe = `<pre id="out" style="font:20px monospace;padding:20px"></pre><script>
const gl = document.createElement('canvas').getContext('webgl');
const info = gl && gl.getExtension('WEBGL_debug_renderer_info');
document.getElementById('out').textContent = !gl ? 'no WebGL'
  : info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER);
</script>`;

const client = new Client(process.env.RS_API_KEY);
await writeFile('probe.png', await client.take(TakeOptions.html(probe).width(900).height(120)));

For real 3D pages, give the scene time to draw: wait for an element the page adds after its first frame with .waitForSelector(), as described in the wait options. We have not published a probe result from the API, and this SDK snippet was not run for this page.

Common mistakes

  • Copying --disable-gpu into every launch. In new headless mode it turns WebGL off.
  • Testing on a laptop, deploying to a GPU-less server. The laptop uses its GPU; the server may get nothing. Run the probe where you capture.
  • Reading the canvas instead of the page. Use page.screenshot(), or create the context with preserveDrawingBuffer: true.
  • Capturing at load. Many WebGL scenes load textures and draw later. Wait for a ready signal.
  • Opening arbitrary URLs with the unsafe SwiftShader flag. Keep it for your own pages.

For a full walkthrough with map tiles, read screenshots of maps and WebGL pages. To try the probe against our renderer, create a free account.

Frequently asked questions

Does headless Chrome support WebGL?
Yes, if it has a GPU or a software renderer to run on. In our tests, new headless mode used the Mac's GPU, chrome-headless-shell used the SwiftShader software renderer, and new headless with --disable-gpu had no WebGL at all.
Why is my WebGL canvas black or transparent in a screenshot?
Either the page got no WebGL context and drew nothing, or your code read the canvas with toDataURL after the frame was shown. Without preserveDrawingBuffer the drawing buffer is cleared, so toDataURL returns empty pixels while page.screenshot still shows the scene.
Is --enable-unsafe-swiftshader safe?
Chrome calls it unsafe because the software renderer runs GPU work in a less isolated way. Use it for pages you control, not for arbitrary URLs from users.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.