Headless Chrome inside Docker can't render WebGL content by default. Your Three.js scene, Mapbox tiles, or Google Maps overlay comes back as a black rectangle or an empty canvas. The page loads fine, JavaScript runs, but the screenshot shows nothing where the 3D content should be. That's because Docker containers don't have a GPU, and Chrome needs one for WebGL.
Or more precisely, Chrome needs something that pretends to be a GPU. There are three ways to give it that, each with different tradeoffs in speed, complexity, and Docker image size. This post covers all three, with Dockerfiles you can copy and run.
Why WebGL breaks in Docker containers
When Chrome starts, it checks what graphics hardware is available. On your laptop, it finds a GPU and uses it for WebGL. Inside a Docker container, there's no /dev/dri device, no GPU driver, nothing. Chrome falls back to SwiftShader, a software-based Vulkan implementation that Google bundles with Chromium. SwiftShader can render WebGL, but it's slow and — here's the part that catches people off guard — starting in Chrome 130, that automatic fallback is being deprecated.
Chrome 139 (rolling out mid-2026 on Linux) experiments with removing SwiftShader as a silent fallback entirely. If you don't explicitly opt in with --enable-unsafe-swiftshader, WebGL context creation just fails. No error in the page console, no crash, just canvas.getContext('webgl') returning null. Your Three.js scene doesn't render. Your Mapbox map shows blank tiles. And your Puppeteer screenshot captures exactly that: nothing.
The Chromium team documented this in issue #40277080 and the corresponding blink-dev Intent to Remove thread. Most Docker-based screenshot guides haven't caught up with this change yet.
How to check if WebGL is actually working
Before fixing anything, how do you figure out what's happening? Run headless Chrome with the --enable-logging=stderr --v=1 flags and look for GPU process output. Or better, capture chrome://gpu as a screenshot — it shows exactly which renderer Chrome picked.
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--enable-webgl',
'--enable-unsafe-swiftshader',
],
});
const page = await browser.newPage();
// Check what renderer Chrome is using
await page.goto('chrome://gpu');
await page.screenshot({ path: 'gpu-status.png', fullPage: true });
// Check WebGL from JavaScript
const webglInfo = await page.evaluate(() => {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) return { supported: false };
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
return {
supported: true,
renderer: debugInfo
? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)
: 'unknown',
vendor: debugInfo
? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL)
: 'unknown',
};
});
console.log(webglInfo);
// SwiftShader: { supported: true, renderer: 'Google SwiftShader', vendor: 'Google Inc.' }
// Mesa: { supported: true, renderer: 'llvmpipe (LLVM 15.0.7, 256 bits)', vendor: 'Mesa' }
await browser.close();
If supported comes back false, Chrome couldn't create a WebGL context at all. If it's true but the renderer says "SwiftShader", you have software rendering working but it'll be slow. The goal is either to make SwiftShader work reliably or switch to Mesa's llvmpipe for better performance.
Fix 1: SwiftShader with explicit opt-in (simplest)
SwiftShader is the path of least resistance. It ships with Chrome, so no extra packages to install. The catch is that you now need to explicitly enable it, because Chrome stopped falling back to it automatically.
FROM node:20-slim
RUN apt-get update && apt-get install -y \
chromium \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "capture.js"]
And the Puppeteer launch with the right flags:
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium',
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--enable-webgl',
'--shm-size=2gb',
],
});
Two things to notice. First, --enable-unsafe-swiftshader is mandatory now. Without it, Chrome 130+ won't fall back to SwiftShader and WebGL silently fails. Second, --shm-size=2gb prevents the BUS_ADRERR crash that happens when Chrome's GPU process runs out of shared memory (Docker's default is 64MB, which isn't enough for WebGL framebuffers).
If you're running docker run instead of Docker Compose, pass --shm-size=2g as a Docker flag, not a Chrome flag.
SwiftShader rendering is slow though. A Three.js scene that takes 200ms on a real GPU takes 8-24 seconds through SwiftShader. For one-off screenshots that's tolerable. For batch processing dozens of WebGL pages, you want something faster.
Fix 2: Mesa llvmpipe (4x faster, no GPU needed)
Mesa's llvmpipe driver renders OpenGL entirely on the CPU using LLVM for JIT compilation. It's multithreaded, uses SIMD instructions, and in Microlink's benchmarks ran WebGL pages about 4x faster than SwiftShader. The setup is more involved because you need a virtual X display, but the performance difference is worth it for any serious workload.
FROM node:20-slim
RUN apt-get update && apt-get install -y \
chromium \
xvfb \
mesa-utils \
libgl1-mesa-dri \
libegl1-mesa \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV DISPLAY=:99
ENV LIBGL_ALWAYS_SOFTWARE=1
ENV GALLIUM_DRIVER=llvmpipe
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Start Xvfb, then run the app
CMD Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp && node capture.js
Chrome flags change slightly for Mesa:
const browser = await puppeteer.launch({
executablePath: '/usr/bin/chromium',
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--use-angle=gl',
'--enable-webgl',
'--shm-size=2gb',
],
});
The key difference is --use-angle=gl instead of --use-angle=swiftshader. This tells Chrome's ANGLE layer to use the system OpenGL driver (Mesa llvmpipe via Xvfb) instead of SwiftShader. Mesa needs a display server to render to, even in headless mode — that's what Xvfb provides. Strange but true: your "headless" Chrome needs a virtual display to get faster WebGL.
Verify it's working by checking the renderer string. You should see "llvmpipe" instead of "SwiftShader" in the WEBGL_debug_renderer_info output.
Fix 3: NVIDIA GPU passthrough (real hardware acceleration)
If your Docker host has an NVIDIA GPU (common on cloud GPU instances, self-hosted ML servers, or some CI runners), you can pass it through to the container. This is the fastest option by far — real GPU rendering at native speed.
# Install NVIDIA Container Toolkit on the host first
# https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html
docker run --gpus all \
--shm-size=2g \
-e NVIDIA_DRIVER_CAPABILITIES=all \
your-screenshot-image node capture.js
Chrome flags for GPU passthrough:
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--enable-gpu-rasterization',
'--use-angle=vulkan',
'--enable-features=Vulkan,UseSkiaRenderer',
],
});
Quick sanity check: run nvidia-smi inside the container. If it shows your GPU, the passthrough is working. If the command isn't found, the NVIDIA Container Toolkit isn't installed or the --gpus flag wasn't passed.
But how many people actually have GPUs in their Docker hosts? CI runners on GitHub Actions, GitLab CI, and most cloud container services don't offer GPU instances (or charge a premium for them). For those environments, Mesa llvmpipe is the practical choice.
The preserveDrawingBuffer gotcha
Got WebGL rendering working but still getting black screenshots? This one burns people even after the fixes above. You load a Three.js scene, it renders correctly in the viewport, but the Puppeteer screenshot shows a black canvas. The page is fine — the screenshot is wrong.
Why? WebGL clears the drawing buffer after compositing each frame. By the time Puppeteer captures the screenshot, the buffer has been wiped. The fix is application-level: the WebGL context needs preserveDrawingBuffer: true.
// If you control the application code:
const renderer = new THREE.WebGLRenderer({
preserveDrawingBuffer: true,
});
// If you DON'T control the application code, inject it via Puppeteer:
await page.evaluateOnNewDocument(() => {
const origGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(type, attrs) {
if (type === 'webgl' || type === 'webgl2') {
attrs = Object.assign({}, attrs, { preserveDrawingBuffer: true });
}
return origGetContext.call(this, type, attrs);
};
});
That evaluateOnNewDocument trick patches getContext before any page script runs, forcing preserveDrawingBuffer on every WebGL canvas. Works with Three.js, Babylon.js, deck.gl, and any library that creates its own WebGL context.
Mapbox GL JS has a different workaround. Pass preserveDrawingBuffer: true in the map constructor options:
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
preserveDrawingBuffer: true,
});
Alpine Linux doesn't have SwiftShader
If you're using node:20-alpine or any Alpine-based image, WebGL won't work reliably. Alpine's Chromium package is built without SwiftShader support. The getContext('webgl') call might return a context object, but actual Three.js scenes will fail to render.
The fix is straightforward: switch to a Debian-based image. node:20-slim is about 80MB larger than Alpine but includes the system libraries that Chromium needs for software rendering. The alpine-chrome issue #231 documents this in detail.
Which libraries are affected
Not every canvas element uses WebGL. chart.js uses Canvas 2D by default and screenshots fine without any of these fixes. Not all of them need WebGL though:
| Library | WebGL required? | Specific fix needed |
|---|---|---|
| Three.js | Yes (core rendering) | preserveDrawingBuffer: true on renderer |
| Mapbox GL JS | Yes (tile rendering) | preserveDrawingBuffer: true in map options + wait for idle event |
| Google Maps (WebGL overlay) | Yes (vector tiles, 3D buildings) | Wait for tiles to load, no preserveDrawingBuffer option exposed |
| deck.gl | Yes | Has a useDevicePixels prop that affects capture quality |
| Cesium | Yes (3D globe) | requestRenderMode: true + explicit scene.render() |
| Babylon.js | Yes | preserveDrawingBuffer: true on engine options |
| Chart.js | No (Canvas 2D) | None — works without WebGL fixes |
| D3.js | No (SVG/Canvas 2D) | None |
| ECharts | Optional (GL extension) | Only if using echarts-gl |
SwiftShader vs Mesa llvmpipe: when to use which
| SwiftShader | Mesa llvmpipe | |
|---|---|---|
| Setup complexity | Low (bundled with Chrome) | Medium (needs Xvfb + Mesa packages) |
| Docker image size increase | ~0 MB (already included) | ~60 MB (mesa-utils, libgl1-mesa-dri, xvfb) |
| Rendering speed (3D page) | 8-24 seconds | 2-6 seconds |
| WebGL 2 support | Partial | Full (OpenGL ES 3.0) |
| Multithreaded | No | Yes (LLVM JIT) |
| Future-proof | Being deprecated | Actively maintained |
For a quick test or a Dockerfile that only occasionally screenshots WebGL content, SwiftShader is fine. For anything running in production — a CI pipeline that captures Mapbox screenshots after every deploy, a reporting service that renders Three.js dashboards — Mesa llvmpipe is the better choice. It's faster, it has better WebGL 2 support, and it won't disappear when Chrome fully removes SwiftShader fallback.
CI/CD platform specifics
Where you run Docker matters because GPU availability varies:
- GitHub Actions — standard runners have no GPU. Use SwiftShader or Mesa. The
ubuntu-latestrunner has enough memory for WebGL but you still need--shm-sizein your Docker run command. - GitLab CI — shared runners have no GPU. Self-hosted runners can have GPUs if you configure the GitLab Runner executor with
--gpus all. - AWS ECS/Fargate — Fargate has no GPU support. ECS on EC2 with G4/G5 instances can use NVIDIA passthrough.
- GCP Cloud Run — no GPU. Use Mesa llvmpipe. Cloud Run does support custom
--shm-sizevia the container spec.
Skip the Docker debugging entirely
Everything above assumes you want to run Chrome yourself. If the WebGL page you're screenshotting is publicly accessible (or accessible through a tunnel), a screenshot API handles all of this for you. The API runs Chrome with the right GPU configuration, the right flags, the right shared memory settings. You send a URL, you get back a screenshot — including WebGL content.
curl "https://api.screenshotrun.com/v1/screenshots/capture" \
-H "Authorization: Bearer YOUR_API_KEY" \
-G \
--data-urlencode "url=https://your-threejs-app.com/scene" \
--data-urlencode "delay=3000" \
--data-urlencode "format=png" \
--data-urlencode "width=1920" \
-o scene.png
The delay parameter matters for WebGL pages. Three.js scenes need a moment to initialize the renderer and draw the first frame. Mapbox needs time to load and render vector tiles. A 3-second delay covers most cases. For heavy scenes with lots of geometry or large textures, use wait_for_selector targeting an element that only appears after the scene finishes loading. You can also inject JavaScript to force preserveDrawingBuffer on the page without modifying its source code.
Does it make sense to maintain a Docker image with Xvfb, Mesa, and a dozen Chrome flags when the same result is one API call? Depends on your volume and whether the pages are public. For CI pipelines screenshotting internal staging environments, you probably need the Docker setup. For capturing public WebGL pages in production, the API removes an entire category of infrastructure headaches. Check the pricing to see if the numbers work.
FAQ#
Docker containers have no GPU hardware. Chrome needs a GPU or software fallback for WebGL rendering. Without proper configuration, canvas.getContext(\webgl) returns null or the drawing buffer gets cleared before the screenshot is captured.
Starting with Chrome 130, the automatic fallback to SwiftShader (software WebGL renderer) is deprecated. This flag explicitly enables SwiftShader as a rendering backend. Without it, WebGL context creation silently fails in newer Chrome versions inside Docker.
Yes, roughly 4x faster in benchmarks. Mesa llvmpipe uses LLVM JIT compilation and multithreading for CPU-based OpenGL rendering. A Three.js scene that takes 8-24 seconds with SwiftShader typically renders in 2-6 seconds with llvmpipe.
WebGL clears its drawing buffer after compositing each frame. By the time the screenshot is captured, the buffer is empty. Set preserveDrawingBuffer: true on the WebGL context or renderer to prevent this.
Not reliably. Alpine's Chromium package is built without SwiftShader support. Switch to a Debian-based image like node:20-slim for consistent WebGL rendering.
Standard shared runners on GitHub Actions and GitLab CI do not have GPUs. Use SwiftShader or Mesa llvmpipe instead. Self-hosted runners with NVIDIA GPUs can use GPU passthrough via the --gpus all Docker flag.
Yes. Mesa's llvmpipe driver needs a display server to render to, even in headless mode. Xvfb (X Virtual Framebuffer) provides that virtual display. Run Xvfb :99 -screen 0 1920x1080x24 before launching Chrome.
At minimum: --no-sandbox, --enable-webgl, and either --enable-unsafe-swiftshader (for SwiftShader) or --use-angle=gl (for Mesa). Also set --shm-size=2gb to prevent shared memory crashes from WebGL framebuffers.