How one Chrome flag made GPU-less WebGL screenshots 4× faster
Cutting 3D render time from ~24s to ~6s
June 29, 2026
A 3D page used to take ~24s to screenshot on Microlink, and now it takes ~6s. The change is a single Chrome flag,
--use-angle=gl, which moves WebGL rendering from SwiftShader to Mesa llvmpipe on servers that have no GPU at all.A WebGL page (three.js) captured as an animated screenshot, rendered through Mesa llvmpipe on a GPU-less node
TL;DR
- Our servers have no GPU. WebGL still has to render somewhere.
- Chrome's default software path (SwiftShader) took ~24s per 3D page.
- Pointing ANGLE at Mesa llvmpipe (
--use-angle=gl) dropped it to ~6s. - The one-line flag is the easy part. The display, the from-source Mesa, and proving it stays on the fast path are the rest of the story.
WebGL sits behind 3D maps, seat charts, product configurators, and shader-art landing pages, and it was the slowest thing you could ask us to capture. The flag is the easy part. The rest of this post covers the X display it needs, the Mesa we compile from source, and the CI check that proves every node stays on the fast path.
Our fleet has no GPU, on purpose
Our browser fleet runs on commodity Linux nodes with no graphics card and no
/dev/dri, which keeps them cheaper, simpler, and free of drivers to babysit. WebGL is still a GPU API, so something has to emulate it on the CPU. Which emulator we picked was the difference between a 24-second screenshot and a 6-second one.ANGLE picks the renderer, not Chrome
Chrome hands WebGL to ANGLE, which translates it to whatever backend the platform has: Direct3D, Metal, native OpenGL or Vulkan, or a software renderer when there is no GPU.
On a GPU-less node, that software renderer decides everything. Chrome can use two of them: SwiftShader, its bundled default, or the system OpenGL stack, which on our Linux nodes is Mesa llvmpipe. Both draw the same pixels on the CPU, at very different speeds.
SwiftShader emulates the whole pipeline conservatively and optimizes for drawing correctly anywhere. A heavy 3D scene takes ~24s with it, while the 2D pages next to it finish in 2-3s. llvmpipe is built differently, and that is where the 4× comes from:
- It JITs to native code. LLVM compiles the live shader and GL state into real x86-64, with no interpreter loop.
- It is tiled and multi-threaded. It spreads the work across every core on the node.
The diff is one line
- '--use-angle=swiftshader',
+ '--use-angle=gl',Two common flags silently undo it, so they must stay out of the launch arguments:
--disable-gpuforces SwiftShader on again. It is the most-copied flag in every headless tutorial.--in-process-gpukills the GL surface ANGLE needs.
Without an X display, WebGL falls back to flat 2D
--use-angle=gl has to bind a GL surface, and that needs an X display even when Chrome runs headless. Without one, WebGL silently degrades to a flat 2D fallback: the screenshot still succeeds, the request still returns 200, and the output is wrong but plausible.Every container therefore boots a virtual display (Xvfb) before Chrome starts, with
LIBGL_ALWAYS_SOFTWARE=1 pinning Mesa to llvmpipe.We build Mesa from source
Ubuntu jammy ships a Mesa that is too old for this, and the PPAs that used to backport newer versions are gone. The base image compiles its own:
meson setup build \
-Dbuildtype=release -Dgallium-drivers=llvmpipe -Dvulkan-drivers= \
-Dllvm=enabled -Dshared-llvm=enabledThe build enables llvmpipe only, skips Vulkan, and links shared LLVM, which is where the JIT speed lives. The toolchain is huge (LLVM, clang, Rust, ~160
-dev packages), so the Dockerfile is multi-stage: it compiles Mesa, then copies only the artifacts into a clean image with COPY. The result is 2.65GB instead of 4.5GB.browserless.report() reads the live GL context
You cannot tell which renderer a node uses by looking at its packages.
apt list reports the wrong version because we side-load Mesa over the package, and the real answer lives inside the page. browserless.report() asks the live GL context directly:const browserless = require('browserless')
const report = await browserless.report()
console.log(report)report
browserless.report() from a production node. Expand gpu and cpu for the full picture.The
gpu block holds the three fields that matter:typeissoftware/llvmpipehere.swiftshaderwould mean we fell back, andhardwarewould mean a GPU appeared.mesais read from the loadedlibgallium-<ver>.so, not from dpkg, which reports the stale package version under our side-load.simdWidth: 256means llvmpipe is using AVX2, which explains most of its speed.
report({ benchmark: true }) adds a deterministic shader benchmark (~300ms on llvmpipe) for comparing nodes against each other.The same report is the CI gate. The flat 2D fallback is dangerous because it looks like success, so CI asserts that
gpu.type is software and gpu.device is llvmpipe. Any drift fails the build instead of shipping flat 3D, and the same call runs against production pods.Benchmarking took weeks, the code took one line
Proving
--use-angle=gl was the right line took weeks of measurement, and most of that time went into two traps:- Dev machines lie. A real GPU renders pages that come back black on prod, so every number had to come from prod-shaped hardware.
- Single runs lie. Cold JIT, first-paint races, and shared cores skew results. The fastest-looking result was sometimes the wrong one: the flat fallback shipped ~1s quicker.
The deterministic benchmark exists for this reason. It runs a fixed shader with forced frames and returns one stable number, so the comparison stopped being anecdotal. SwiftShader landed at ~24-31s, and llvmpipe at ~6s warm and correct.
Production numbers: ~4× isolated, ~2× under load
All numbers below come from the same 3D chart on the same GPU-less hardware, measured on production:
| SwiftShader (before) | Mesa llvmpipe (after) | |
|---|---|---|
| Render time (isolated) | ~24s | ~6s (~4×) |
| Render time (under load) | ~24s | 7–14s (~2×) |
| Failed requests | timed out → errors | none |
| Active renderer | SwiftShader | llvmpipe (asserted in CI) |
Isolated, the chart finishes in ~6s. Under real traffic, where captures share cores, expect ~2×. In both cases, the requests that used to time out now finish.
The following examples show how to use the Microlink API with CLI, cURL, JavaScript, Python, Ruby, PHP & Golang, targeting 'https://threejs.org/examples/webgl_animation_skinning_blending' URL with 'screenshot' API parameter:
CLI Microlink API example
microlink https://threejs.org/examples/webgl_animation_skinning_blending&screenshot.animatedcURL Microlink API example
curl -G "https://api.microlink.io" \
-d "url=https://threejs.org/examples/webgl_animation_skinning_blending" \
-d "screenshot.animated=true"JavaScript Microlink API example
import createClient from 'microlink.io'
const microlink = createClient()
const data = await microlink.screenshot('https://threejs.org/examples/webgl_animation_skinning_blending', {
animated: true
})Python Microlink API example
import requests
url = "https://api.microlink.io/"
querystring = {
"url": "https://threejs.org/examples/webgl_animation_skinning_blending",
"screenshot.animated": "true"
}
response = requests.get(url, params=querystring)
print(response.json())Ruby Microlink API example
require 'uri'
require 'net/http'
base_url = "https://api.microlink.io/"
params = {
url: "https://threejs.org/examples/webgl_animation_skinning_blending",
screenshot.animated: "true"
}
uri = URI(base_url)
uri.query = URI.encode_www_form(params)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Get.new(uri)
response = http.request(request)
puts response.bodyPHP Microlink API example
<?php
$baseUrl = "https://api.microlink.io/";
$params = [
"url" => "https://threejs.org/examples/webgl_animation_skinning_blending",
"screenshot.animated" => "true"
];
$query = http_build_query($params);
$url = $baseUrl . '?' . $query;
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET"
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #: " . $err;
} else {
echo $response;
}Golang Microlink API example
package main
import (
"fmt"
"net/http"
"net/url"
"io"
)
func main() {
baseURL := "https://api.microlink.io"
u, err := url.Parse(baseURL)
if err != nil {
panic(err)
}
q := u.Query()
q.Set("url", "https://threejs.org/examples/webgl_animation_skinning_blending")
q.Set("screenshot.animated", "true")
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
panic(err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}import createClient from 'microlink.io'
const microlink = createClient()
const data = await microlink.screenshot('https://threejs.org/examples/webgl_animation_skinning_blending', {
animated: true
})Try it: a WebGL page captured live through ANGLE → Mesa llvmpipe. See the animated screenshot docs for the parameters.
Heavy shader heroes can still come back black
Software GL closes most of the gap, not all of it. Heavy fragment-shader heroes can still come back black because the canvas has not painted by capture time. That is a first-paint race, not a renderer problem, and no flag fixes it. The two real fixes are gating capture on first paint or adding real GPUs, and we are building the first one.
For everything else, moving from SwiftShader to llvmpipe turned our slowest, flakiest requests into ordinary ones. Run the example above against your own WebGL page with the animated screenshot parameters to see it.
Join the community
All of these improvements or features are community driven: We listen to your feedback and act accordingly.
Whether you are building a product, an indie developer, or just interested in web technologies, come chat with us.