Skip to content

Faster WebGL screenshots

How we made 3D pages render up to 3× faster

Kiko Beats

June 15, 2026 ()

More of the web is rendered with WebGL than you'd think: interactive 3D maps, seating charts, data visualizations, product configurators, even whole games. They look great in a browser — and, until recently, they were the slowest thing you could ask Microlink to screenshot.
Today that changes. WebGL pages now render up to 3× faster, and the timeouts that used to fail them are gone.

The problem

A customer running 3D seat maps reported that their thumbnails were painfully slow — around 24 seconds each — and that some never finished at all, coming back as errors.
When we pulled the traces, the picture was clear: nearly all of that time was spent inside the browser, waiting for the 3D scene to paint. The 2D pages next to them rendered in 2–3 seconds. Only the WebGL ones were slow, and the slowest of them hit our render timeout and failed outright.

Why it happened

WebGL is GPU technology. But the servers that run a headless browser at scale — ours included — don't have a GPU.
When Chrome can't find a GPU, it falls back to SwiftShader, a software renderer that emulates a graphics card on the CPU. It's correct and portable, which is exactly why it's the default. It's also slow: for a geometry-heavy 3D scene, software-emulating a full graphics pipeline on the CPU is brutal. That's the 24 seconds.
2D content (SVG, canvas) doesn't go through this path, which is why only WebGL pages were affected.

What changed

There's a faster way to render graphics on the CPU: , a software rasterizer that JIT-compiles the graphics pipeline to native machine code with LLVM and spreads the work across every core. For a geometry-heavy 3D scene, that design churns through the work far faster than SwiftShader.
So we switched — our own headless browser runner behind the API — to route WebGL through Mesa llvmpipe instead of SwiftShader. We validated it end-to-end on production, on the exact pages that were slow, confirming the renderer was llvmpipe and the output was pixel-identical.

The numbers

Same 3D chart, same hardware, measured on production:
Before (SwiftShader)After (Mesa llvmpipe)
Render time~23.6s7–14s
Failed requeststimed out → errorsnone
Outputcorrect 3Dcorrect 3D
In isolation — a render with cores to spare — the same chart now finishes in about 6 seconds, a 4× improvement. Under real production traffic, where many captures share each machine, you can expect closer to . Either way, the requests that used to time out now comfortably finish.

What you get

Nothing to configure. If you screenshot or PDF pages that use WebGL — maps, charts, 3D viewers, games — they're simply faster and more reliable now.

The following examples show how to use the Microlink API with CLI, cURL, JavaScript, Python, Ruby, PHP & Golang, targeting 'https://get.webgl.org/' URL with 'screenshot' API parameter:

CLI Microlink API example

microlink https://get.webgl.org/&screenshot

cURL Microlink API example

curl -G "https://api.microlink.io" \
  -d "url=https://get.webgl.org/" \
  -d "screenshot=true"

JavaScript Microlink API example

import mql from '@microlink/mql'

const { data } = await mql('https://get.webgl.org/', {
  screenshot: true
})

Python Microlink API example

import requests

url = "https://api.microlink.io/"

querystring = {
    "url": "https://get.webgl.org/",
    "screenshot": "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://get.webgl.org/",
  screenshot: "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.body

PHP Microlink API example

<?php

$baseUrl = "https://api.microlink.io/";

$params = [
    "url" => "https://get.webgl.org/",
    "screenshot" => "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://get.webgl.org/")
    q.Set("screenshot", "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))
}
A WebGL page captured with Microlink — now rendered through Mesa llvmpipe

Under the hood

Chrome doesn't rasterize WebGL itself — it delegates to ANGLE, which targets a backend. The change is in the flags launches Chrome with:
  • before: --use-angle=swiftshader, the self-contained software path.
  • after: --use-angle=gl, which binds ANGLE to the system OpenGL stack — Mesa llvmpipe on our GPU-less Linux nodes.
The one catch: the GL path needs a display to bind a surface to, even in headless mode. So our images now boot a virtual display () before the browser starts. No display, and WebGL silently degrades back to a flat 2D fallback — which we specifically guard against in CI by asserting the active renderer is always llvmpipe, never SwiftShader.
If you want the broader context on the stack, see what is a headless browser?.

Final notes

No software renderer will ever match a real GPU. But for the headless, server-side rendering that powers Microlink, moving from SwiftShader to llvmpipe closes most of the gap — and turns WebGL captures from the slowest, most fragile requests we served into ordinary ones.
If you want more context around the stack, read:

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.