Detecting WebGL Render Completion in Headless Chromium

You gate capture on the map’s idle event, the network is quiet, and the screenshot still lands one frame early — a gradient band half-composited, a symbol layer that has not yet been rasterized to the framebuffer. The reason is that WebGL draw calls are asynchronous: gl.drawArrays() and gl.drawElements() return the instant the command is queued, long before the GPU has flushed the back buffer to the surface Chromium reads. This page gives a concrete, copy-ready procedure for headless Chromium that proves the current frame is genuinely drawn — not merely scheduled — before the shutter fires, using the map engine’s own render loop plus a compositor yield rather than a fence you are not allowed to read.

This is one task inside WebGL Idle & Render-Completion Detection, the guide that treats the gap between “the engine says idle” and “the GPU has painted” as its own synchronization problem. It sits under the broader pipeline in Screenshot Capture, Sync & Comparison Logic, and it assumes tile hydration is already handled per how to wait for all map tiles to load before a screenshot — this procedure is the last gate, after the data has arrived.

Prerequisites

Step-by-step procedure

1. Launch Chromium with a deterministic software GL backend

A discrete host GPU and Chromium’s SwiftShader rasterizer flush the pipeline on different schedules and dither gradients differently, so a render-completion signal calibrated on your laptop will not hold on a CI node. Pin the software backend and disable GPU acceleration at launch, and the “when is the frame done” question has one answer everywhere.

import { chromium } from 'playwright';

const browser = await chromium.launch({
  args: [
    '--headless=new',
    '--use-gl=angle',
    '--use-angle=swiftshader', // software rasterization, no host GPU
    '--disable-gpu',
    '--force-color-profile=srgb',
    '--force-device-scale-factor=1',
  ],
});

const context = await browser.newContext({
  viewport: { width: 1024, height: 768 },
  deviceScaleFactor: 1,
});
const page = await context.newPage();

With SwiftShader driving WebGL, the same commit produces the same composited pixels on every runner, which is the precondition for any render-completion check to mean something.

2. Expose the map instance on window

Every signal in this procedure comes from the engine’s render loop, so the test build must hand the map object to page scope. Do this once, at construction, guarded so it never ships to production.

// app entry, test build only
const map = new maplibregl.Map({
  container: 'map',
  style: '/style.json',
  center: [-122.4194, 37.7749],
  zoom: 12,
  fadeDuration: 0,           // no fade frames after tiles settle
  crossSourceCollisions: false,
});

if (window.__UNDER_TEST__) {
  window.__map = map;        // reachable from page.evaluate
}

Without this handle, page.evaluate has nothing to subscribe to, and you are back to guessing with a setTimeout.

3. Subscribe to the render loop and read the frame state

MapLibre and Mapbox emit render on every animation frame the engine draws, and idle once it has drawn the last frame it intends to for the current camera and has no further work queued. idle is the right coarse signal, but it describes the engine’s intent, not the GPU’s state — the frame it refers to may still be in flight. Subscribe to render so you can inspect the frame the engine just issued, and treat idle as the trigger to start the fine-grained checks.

await page.evaluate(() => new Promise((resolve) => {
  const map = window.__map;
  // idle means "no more frames scheduled" — necessary, not sufficient.
  if (map.loaded() && !map.isMoving() && map.areTilesLoaded()) resolve();
  else map.once('idle', resolve);
}));

Resolving here means the engine has stopped scheduling frames. The next two steps prove the last of those frames actually reached the framebuffer.

4. Build a render-complete promise from source and layer state

idle can fire in the narrow window between the engine issuing its final draw and the source finishing its transition, so confirm the render pipeline’s own readiness predicates before trusting the frame. Resolve only once every source reports loaded, the layers you expect to see exist in the style, and the aggregate areTilesLoaded() is true. Listen on render rather than polling, so the check re-runs on each frame the engine draws and latches on the first frame where all three hold.

await page.evaluate(() => new Promise((resolve) => {
  const map = window.__map;
  const required = ['background', 'water', 'roads', 'place-labels'];

  const drawn = () =>
    map.areTilesLoaded() &&
    map.isSourceLoaded('composite') &&
    required.every((id) => map.getLayer(id));

  const check = () => {
    if (drawn()) {
      map.off('render', check);
      resolve();
    }
  };
  map.on('render', check);
  check();                    // handle the already-drawn case
}));

This is the portable substitute for a GPU fence: instead of asking the driver whether the buffer flushed, you ask the engine whether the state that produces the buffer is complete, on the same frame clock that produced it.

5. Yield a double requestAnimationFrame for the compositor

Even after the engine’s last render, Chromium’s compositor needs one more frame to promote the drawn WebGL back buffer into the surface a screenshot reads. A single requestAnimationFrame callback fires before that commit; capturing inside it still races the compositor. Yield twice — the second callback runs only after the frame containing the first has been composited and presented, which is the deterministic proof that the pixels are on screen.

await page.evaluate(() => new Promise((resolve) => {
  requestAnimationFrame(() =>
    requestAnimationFrame(() => resolve())
  );
}));

The double-rAF is cheap — two frame intervals, roughly 32 ms — and it is the single most reliable line in the procedure. It replaces every “add a 200 ms sleep and hope” workaround with a wait that is exactly as long as the compositor needs and no longer.

6. Understand why GPU fence queries are unavailable headless

The textbook answer to “is the GPU done” is a fence: WebGLSync with gl.fenceSync() / gl.clientWaitSync() in WebGL 2, or the EXT_disjoint_timer_query / gl.getQueryObject() path in WebGL 1. In headless Chromium these are usually a dead end. Timer queries are gated behind the same fingerprinting and privacy protections that disable high-resolution GPU timing in the browser, so the extension is frequently absent, and even where a WebGLSync object is created, SwiftShader’s synchronous software pipeline makes clientWaitSync degenerate — it reports signalled immediately because there is no asynchronous GPU queue to wait on. You get an API that either does not exist or answers “done” before the compositor has presented, which is worse than useless for a capture gate. The engine’s render loop plus the double-rAF is the portable path precisely because it observes the browser’s own present cycle rather than a driver primitive the sandbox has taken away.

Timeline from the engine render loop to a safe capture A left-to-right timeline over the browser frame clock. First the map engine's render loop draws successive frames while sources and layers finish loading. The idle event fires when no further frames are scheduled, which starts the render-complete check that latches once areTilesLoaded, isSourceLoaded, and the required layers all hold on one render. A first requestAnimationFrame callback runs before the compositor commit and is unsafe to capture in. A second requestAnimationFrame callback runs only after the drawn back buffer has been composited and presented — the safe capture point where the screenshot reads the fully drawn frame. A blocked box marks GPU fence queries such as clientWaitSync and getQueryObject as unavailable under headless SwiftShader. browser frame clock → render loop frames drawn, tiles decode idle event no more frames scheduled render-complete sources + layers on one render rAF #1 before commit · unsafe rAF #2 composited · capture GPU fence (clientWaitSync / getQueryObject) — blocked headless

7. Capture immediately, then prove it with a two-capture stability check

Once the second requestAnimationFrame resolves, screenshot with no further delay — any extra timeout only opens a window for a background repaint. Then verify the gate empirically: capture the same locked viewport twice in a row and confirm the two frames are identical (or pass a near-zero diff). Two captures of the same code that differ mean the frame was still mutating, so a signal in steps 4–5 is missing; two identical captures are the proof that render completion is genuinely detected.

async function capture() {
  await gateOnRenderComplete(page);          // steps 3–5
  return page.locator('#map').screenshot({ animations: 'disabled' });
}

const first = await capture();
const second = await capture();

const changed = pixelmatch(
  PNG.sync.read(first).data,
  PNG.sync.read(second).data,
  null, 1024, 768, { threshold: 0.1, includeAA: false }
);
if (changed > 0) throw new Error(`Frame still mutating: ${changed} px differ between two captures`);

A zero-difference pair is the objective signal that the frame is fully drawn and stable — exactly the guarantee a baseline comparison needs before it can trust any diff against a golden.

Verification

Confirm the render-completion gate holds before wiring it into CI:

Troubleshooting

Symptom Likely cause Fix
Two identical-code captures differ by a thin band of pixels Captured inside the first requestAnimationFrame, before the compositor promoted the WebGL back buffer to the surface Yield a second requestAnimationFrame (step 5) so capture happens after the present, not before the commit
gl.getQueryObject / clientWaitSync returns “done” but the frame is still partial Software SwiftShader signals the fence synchronously with no real GPU queue, and timer queries are disabled headless Drop the fence path entirely; gate on the engine render loop plus double-rAF as in steps 4–5
Passes locally, flakes only in CI Host GPU composited on the laptop while the runner used SwiftShader, so the “done” frame differs sub-pixel Force --use-angle=swiftshader --disable-gpu on both, then absorb residual variance via Noise Reduction for Map Artifacts

Frequently asked questions

Why is the idle event not enough to know the WebGL frame is drawn?

Because idle reports the engine’s intent — that it has no more frames scheduled for the current camera — not the GPU’s state. WebGL draw calls return immediately while the buffer is still flushing, and Chromium’s compositor needs a further frame to present it. Latch a render-complete predicate on the render loop, then yield a double requestAnimationFrame, and only then is the drawn frame actually on the surface a screenshot reads.

Can I use gl.clientWaitSync or a timer query to detect GPU completion headless?

Usually not. EXT_disjoint_timer_query and getQueryObject are commonly disabled under headless Chromium as an anti-fingerprinting measure, and under SwiftShader a WebGLSync from fenceSync signals synchronously because there is no asynchronous GPU queue to wait on — it reports done before the compositor has presented. Observe the browser’s own present cycle with the render loop plus double-rAF instead of a driver primitive the sandbox removes.

Why does capture need two requestAnimationFrame calls rather than one?

A single requestAnimationFrame callback runs before the compositor commits the frame it belongs to, so capturing inside it still races the present. The second callback runs only after the frame containing the first has been composited and shown, which is the deterministic point where the drawn WebGL back buffer has become the visible surface. It costs about two frame intervals and replaces every guessed setTimeout.

How do I prove the render-completion gate actually works?

Run the two-capture stability check: screenshot the same locked viewport twice back-to-back and diff the pair. Identical frames (zero changed pixels with anti-aliasing ignored) prove the frame was fully drawn and stable at capture time; any difference means the frame was still mutating and a signal in the source/layer readiness check is missing. Repeat it under CPU throttling to confirm the wait scales with render time rather than leaning on a hidden timeout.