WebGL Idle & Render-Completion Detection

A headless screenshot of a WebGL map can be wrong even when every tile has arrived. The bytes are decoded, the sources report loaded, the network is silent — and the shutter still catches a frame that is half-composited, one draw call behind, or blank because the GPU had not yet presented the surface the compositor sampled. The failure is not in transport; it is in the render loop. gl.drawElements() returns to JavaScript long before the GPU has executed the command and long before the browser compositor has copied the canvas into the page image a screenshot reads. This page is about closing that specific gap: knowing, deterministically, that the current frame is drawn and presented before capture fires, so the comparison stage never sees a mid-draw artifact that no amount of threshold tuning can explain.

This area extends the synchronization stage of Screenshot Capture, Sync & Comparison Logic, and it is the render-loop sibling of Handling Async Tile Loading. Where that guide answers have all the tiles hydrated?, this one answers the question that survives even after every tile is in memory: has the GPU finished painting the frame those tiles belong to, and has the browser presented it? The two gates stack — tile hydration is necessary, render completion is what proves the pixels are actually on the surface.

What “render complete” means below the tile layer

The word “rendered” hides three separate machines, each of which can lag the one before it, and each of which a naive capture confuses for the others.

The first is the map engine’s render loop. MapLibre GL JS and Mapbox GL JS drive rendering with requestAnimationFrame: when something changes — a tile arrives, the camera moves, a fade transition advances, a symbol de-collides — the engine schedules a repaint, executes the style, and issues WebGL draw calls for that frame. It emits a render event once per painted frame and an idle event when it has decided no further frames are needed for the current camera and data. idle is the engine saying my work queue is empty. It is not the GPU saying I have finished drawing, and it is not the browser saying I have shown the result.

The second is the GL command stream. WebGL calls are asynchronous with respect to the GPU. gl.drawElements() and gl.drawArrays() enqueue work into a command buffer and return immediately; the driver executes that buffer later, batched with others. This is why idle firing tells you nothing about GPU progress — the engine can finish issuing calls, empty its own queue, and emit idle while the driver still has draws pending. The only synchronous way to force the CPU to wait for the GPU is gl.finish(), which blocks until every queued command completes, but it is a heavy per-frame stall and, critically, it still says nothing about the third machine.

The third is the browser compositor. A WebGL canvas is not the screenshot surface. The browser composites the canvas into the page on its own schedule, and a headless screenshot samples that composited output. Even after the GPU has flushed a frame into the canvas backbuffer, that frame is only guaranteed to be visible to a capture after it has been presented — which happens at a compositor tick, one frame boundary later. This is the race that makes idle alone unreliable: the engine goes idle on frame N, but the surface a screenshot reads may still hold frame N−1 until the next presentation.

Render completion, then, is the conjunction of all three: the engine has stopped scheduling frames, the GPU has flushed the draws for the last one, and the compositor has presented it. A robust detector never trusts a single event to prove all three at once. This is the same layered-signal discipline used for deterministic tile capture, applied one level deeper in the pipeline.

Why idle races the compositor, and the settle pattern that fixes it

Because idle fires at the boundary between the engine’s work ending and the compositor’s presentation beginning, the fix is to wait past that boundary — to let the browser advance far enough that the frame the engine just finished is guaranteed to have been presented. The portable primitive for this is requestAnimationFrame, and the reliable pattern is a double rAF:

await new Promise((resolve) =>
  requestAnimationFrame(() => requestAnimationFrame(resolve))
);

A single requestAnimationFrame callback runs before the paint of the frame it is scheduled for. Its callback fires, the browser then lays out and paints, and the frame is presented at the end of that cycle. Scheduling a second rAF from inside the first guarantees the callback resolving your promise runs on the frame after the one that was in flight when idle fired — by which point the previous frame has been composited and presented, and the screenshot surface holds it. One rAF is not enough precisely because it resolves before the presentation it is trying to wait for; two brackets the boundary.

The double rAF is a timing guarantee, not a stillness guarantee. It proves a frame boundary has passed, but not that the map is actually static — an animated style keeps repainting on every rAF forever. The stronger form counts consecutive frames with no repaint: hook the engine’s render event, and every time a frame renders, reset a counter; every rAF tick with no intervening render, increment it. When the counter reaches N (two is a good default), the engine has been quiet across N full presentation cycles and the surface is stable. This distinguishes “the compositor caught up” from “the map genuinely stopped moving,” which the next section relies on.

Render-completion timeline: engine render events, the idle event, and the double-rAF settle window before capture A horizontal time axis runs left to right across presentation frames. On the left, several engine render events fire on successive frames as tiles paint. The engine then emits the idle event, but the arrow shows the compositor has only presented the previous frame at that instant, so capturing here would read a stale surface. To the right, two consecutive requestAnimationFrame ticks pass with no render event — the settle window — during which the last drawn frame is presented and the surface stops mutating. Only after the second quiet frame does capture fire. Below, a layered predicate stack lists the four conditions that must all be true: engine idle, areTilesLoaded true, not moving or rotating, and N no-repaint frames. presentation frames render events (frames painting) idle event compositor still on frame N−1 double-rAF settle: 2 no-repaint frames capture ✓ CAPTURE PREDICATE (ALL TRUE) engine idle fired areTilesLoaded() !isMoving / !isRotating N no-repaint frames

The composite predicate: combining engine, data, and frame signals

No single signal proves render completion, so the design pattern is a composite predicate that requires every load-bearing signal to hold at the same instant. Layer them so each covers the blind spot of the others:

  1. Engine idle — the engine has emptied its own frame queue for the current camera and data. Necessary, but it fires transiently and precedes presentation.
  2. areTilesLoaded() — every source’s tiles for the current viewport have resolved, so idle did not fire in a brief lull between two tile batches. This is the tile-hydration contract from Handling Async Tile Loading, reused as one input here.
  3. Camera stillness!map.isMoving() && !map.isRotating(), so no easing or inertia is mid-flight. This is the camera lock enforced upstream by Viewport & Zoom Sync Strategies; the predicate simply re-asserts it at capture time.
  4. N no-repaint frames — the render event has not fired across N consecutive rAF ticks, proving both that the compositor has presented the last frame and that nothing is still animating.

The predicate is evaluated on every idle and every render, not once, because idle can fire more than once during a load — a fractional zoom that triggers a second pyramid level, a late glyph that forces a relayout, a fade transition that resumes. Resolving on the first idle is the single most common cause of a mid-draw capture; requiring all four conditions and re-checking on each event closes it. The fully wired, fixture-backed implementation of this predicate for headless Chromium lives in detecting WebGL render completion in headless Chromium.

Step-by-step: a render-completion gate

The procedure assumes the camera is already pinned to an integer zoom with interaction disabled, and the tile network has been intercepted per the tile-loading guide. It adds the render-loop layer on top.

  1. Expose the map and instrument the render loop. Attach a per-frame counter driven by the engine’s render event so the harness can measure quiet frames. Do this before navigation completes so the very first frame is counted.

    await page.evaluate(() => {
      const map = window.__testMap;
      window.__lastRender = performance.now();
      map.on('render', () => { window.__lastRender = performance.now(); });
    });
    
  2. Wait for the composite predicate on idle. Resolve only when the engine is idle, tiles are loaded, and the camera is still — re-checking on each idle rather than trusting the first.

    await page.evaluate(() => new Promise((resolve) => {
      const map = window.__testMap;
      const ready = () =>
        map.areTilesLoaded() && !map.isMoving() && !map.isRotating();
      const check = () => { if (ready()) { map.off('idle', check); resolve(); } };
      map.on('idle', check);
      check();                      // handle the already-idle case
    }));
    
  3. Require N consecutive no-repaint frames. Bridge the compositor race: tick requestAnimationFrame, and only resolve once enough frames have elapsed since the last render. Reset the streak whenever a repaint sneaks in.

    await page.evaluate((needed) => new Promise((resolve) => {
      let quiet = 0, mark = window.__lastRender;
      const tick = () => {
        if (window.__lastRender !== mark) { quiet = 0; mark = window.__lastRender; }
        else if (++quiet >= needed) return resolve();
        requestAnimationFrame(tick);
      };
      requestAnimationFrame(tick);
    }, 2));
    
  4. Force the GL command stream to flush (optional belt). On a real-GPU runner, issue a finish() on the map’s context so any pending draw completes before the compositor samples it. On SwiftShader this is close to a no-op, but it costs little and hardens host-GPU runs.

    await page.evaluate(() => {
      const gl = window.__testMap.painter.context.gl;
      gl.finish();                  // block until queued GPU work completes
    });
    
  5. Capture the canvas with animations frozen. Screenshot the map element so any residual CSS transition is held, not caught mid-ease. Pair this with the CSS-level controls in Animation & Transition Suppression so no DOM overlay animates independently of the GL surface.

    const buffer = await page.locator('#map').screenshot({ animations: 'disabled' });
    
  6. Guard against never-idle styles. Wrap steps 2–3 in a bounded timeout. A style with a continuous animation loop will never satisfy the predicate; the timeout converts an infinite hang into a fast, diagnosable failure that points you at animation suppression rather than at a flaky tile.

    await Promise.race([
      renderCompleteGate(page),
      page.waitForTimeout(8000).then(() => { throw new Error('render never settled'); }),
    ]);
    

Why GPU fences and timer queries do not save you

It is tempting to ask the GPU directly whether it is done, rather than inferring it from frame counts. Two mechanisms exist, and both are largely unavailable in the exact environment visual regression runs in.

WebGL 2 sync objectsgl.fenceSync(), gl.clientWaitSync(), gl.getSyncParameter() — let you insert a fence into the command stream and poll whether the GPU has passed it. In principle this proves GL-level completion. In practice you do not own the map’s draw calls: the engine issues them inside its own render function, and inserting a fence after the fact tells you only that your trailing no-op has flushed, not that the frame the engine drew is presented. Sync objects also prove GPU completion, not compositor presentation — the very boundary that trips idle. They narrow one gap while leaving the one that actually bites you open.

Timer queriesEXT_disjoint_timer_query_webgl2, read back with gl.getQueryParameter(query, gl.QUERY_RESULT) — would let you measure GPU draw duration. But this extension is disabled in almost every browser by default, and specifically in headless contexts, because high-resolution GPU timing is a side channel for cross-origin and fingerprinting attacks. gl.getExtension('EXT_disjoint_timer_query_webgl2') typically returns null under headless Chromium, so any detector built on it silently degrades to no signal at all.

The practical consequence: the portable, always-available proof of render completion is the engine render loop plus rAF frame counting, not a GPU query. Fences are a marginal hardening on real-GPU runners where you control the draw; timer queries are effectively off the table. Build the gate on the render loop and treat gl.finish() as the only GPU-level primitive you can rely on across environments.

Detecting continuous animation loops that never idle

Some map states never go idle by design. An animated raster source (radar, weather, traffic replay) calls map.triggerRepaint() every frame; a symbol layer with icon-image keyframes, a pulsing marker, a raster-fade-duration that loops, or a WebGL custom layer running its own animation all keep the engine painting forever. Against these, the composite predicate can never be satisfied and the gate hangs until its timeout.

Detect this case explicitly rather than absorbing it into a longer timeout. Sample the render event rate over a short window: if the engine keeps painting at roughly display refresh with no tile activity and the camera still, you are looking at a continuous animation, not an unfinished load.

const painting = await page.evaluate(() => new Promise((resolve) => {
  const map = window.__testMap;
  let frames = 0;
  const onRender = () => frames++;
  map.on('render', onRender);
  setTimeout(() => { map.off('render', onRender); resolve(frames); }, 500);
}));
// frames near 30 over 500 ms with tiles loaded and camera still => animated style

Once detected, the fix is not in this gate — it is to stop the animation before capture, by freezing the layer or pinning it to a fixed keyframe. That is the job of Animation & Transition Suppression, which suppresses the repaint source so the render loop can actually reach idle. With the animation frozen, the composite predicate settles normally and the residual per-frame WebGL noise that survives is handled downstream by Noise Reduction for Map Artifacts.

Cross-browser and cross-environment considerations

Render-completion timing, and the exact pixels it produces, diverge across browser engines and GPU backends. The gate logic is portable; the values and guarantees behind it are not.

  • Chromium with ANGLE + SwiftShader is the reference target. --use-gl=angle --use-angle=swiftshader routes WebGL through ANGLE onto a software rasterizer, which makes output deterministic across CI nodes regardless of host GPU and makes the render loop’s timing stable. Because SwiftShader executes on the CPU, gl.finish() is cheap and the compositor race is short but still real — keep the no-repaint frame count.
  • Chromium on a host GPU renders faster but non-deterministically: driver-dependent dithering and anti-aliasing mean two machines produce different pixels even with an identical gate. Reserve host-GPU runs for performance work and pin SwiftShader for baseline comparison, keeping a separate baseline per backend if you must run both — the same per-engine baseline discipline tracked in baseline image versioning for web maps.
  • WebKit (Playwright) does not expose the ANGLE/SwiftShader flags, so its compositor schedule and subpixel output differ from Chromium. The double-rAF settle still holds, but expect a distinct baseline and treat gl.finish() as unavailable through the same path.
  • Firefox advances its off-thread compositor on a different cadence, so the idle-then-present gap is wider; the no-repaint frame count matters most here, and two frames may need to be three under load.
  • Containerization. Pin the browser image so the GL stack, ANGLE version, and driver are fixed. A backend change between the baseline run and the comparison run shifts anti-aliasing across the whole frame and reads as a full-frame regression that no threshold can absorb.

Signals reference: what each proves and its blind spot

Every signal in the render pipeline proves something narrow. Read this as what you are allowed to conclude from each, and what it stays silent about — the blind spot is why the composite predicate needs the others.

Signal What it proves Blind spot
networkidle (browser) Byte transport has stopped Nothing about decode, GPU, or paint — fires while workers still parse
map.on('idle') Engine’s frame queue is empty for now Fires before compositor presents; can fire transiently between tile batches
map.areTilesLoaded() Every source’s viewport tiles resolved Says nothing about whether the frame using them is drawn or presented
!isMoving() && !isRotating() No easing or inertia in flight A style animation still repaints with the camera perfectly still
gl.drawElements() returned Draw command was enqueued GPU has not executed it; frame not on the surface
gl.finish() returned Queued GPU work has completed Compositor may still not have presented the canvas to the screenshot surface
Single requestAnimationFrame A frame callback ran Resolves before the paint it was scheduled for — one rAF too early
N no-repaint frames Compositor presented last frame; map is static Requires an upper bound, or an animated style hangs it forever
WebGL 2 fenceSync Your inserted fence passed on the GPU You do not own the engine’s draws; still not compositor presentation
EXT_disjoint_timer_query GPU draw duration (when available) Disabled in headless browsers as a timing side channel — usually null

Threshold & parameter reference table

Starting values for the render-completion gate. Tune the frame count up for Firefox and loaded CI nodes, down for a warm SwiftShader run serving local fixtures.

Parameter Recommended value Rationale
No-repaint settle frames (N) 2 (Chromium), 3 (Firefox/WebKit) Brackets the compositor presentation boundary after idle
Predicate re-check on every idle and render idle fires transiently; a late glyph or fade reopens the queue
Render-gate timeout 8000 ms Converts a never-idle animated style into a fast, diagnosable failure
Animation-detection window 500 ms Long enough to distinguish a continuous loop from a settling load
gl.finish() before capture on (host GPU), optional (SwiftShader) Flushes pending draws where GPU timing varies; near-free on software
GL backend angle + swiftshader Deterministic rasterization and stable loop timing across runners
Screenshot animations disabled Freezes residual CSS transitions on DOM chrome over the canvas

Common pitfalls

Capture resolves on the first idle and catches a mid-draw frame

Root cause: the harness awaited a single idle, which fired in a lull before a second tile batch or a fade transition resumed, so the frame was still changing. Diagnose: log map.areTilesLoaded() and a frame counter at capture time; a false or a still-advancing counter on the failing run confirms it. Fix: evaluate the full composite predicate — idle and areTilesLoaded() and camera still and N no-repaint frames — and re-check it on every idle and render rather than resolving on the first event.

A single requestAnimationFrame still captures the previous frame

Root cause: one rAF callback runs before the paint of the frame it is scheduled for, so resolving inside it reads the surface one presentation too early. Diagnose: two captures from the same idle state differ by exactly one frame’s worth of content — the newer draw is missing. Fix: use a double rAF (requestAnimationFrame(() => requestAnimationFrame(resolve))) or, better, the N-no-repaint-frame counter, which guarantees the last drawn frame has been presented.

The gate hangs forever and eventually times out

Root cause: a continuous animation — an animated raster/weather/traffic source, a looping symbol keyframe, or a custom WebGL layer — calls triggerRepaint() every frame, so the engine never emits a lasting idle and the no-repaint counter never fills. Diagnose: sample the render rate over 500 ms with tiles loaded and the camera still; a rate near display refresh means a live animation, not an unfinished load. Fix: freeze the animated layer before capture via Animation & Transition Suppression, then let the predicate settle normally.

Building the detector on GPU fences or timer queries yields no signal

Root cause: EXT_disjoint_timer_query_webgl2 is disabled in headless browsers as a timing side channel, so getExtension returns null; and WebGL 2 sync objects prove GPU completion of your fence, not compositor presentation of the engine’s frame. Diagnose: log the return of gl.getExtension('EXT_disjoint_timer_query_webgl2') in the headless context — a null means the whole path is dead. Fix: drive completion from the engine render loop and rAF frame counting; keep gl.finish() as the only GPU primitive you rely on, and only on host-GPU runs.

Passes on a host GPU, flakes on the SwiftShader CI node

Root cause: the host GPU presents faster and dithers differently, so a gate tuned to two quiet frames locally is fine, but the CI backend’s compositor cadence — or a backend mismatch against the baseline — shifts pixels across the frame. Diagnose: run the same commit twice on the CI node and diff the two current captures; persistent full-frame noise points at the backend, not a regression. Fix: pin --use-gl=angle --use-angle=swiftshader for both baseline and comparison, raise N if the compositor is slower, and absorb residual subpixel variance with Noise Reduction for Map Artifacts.

Frequently asked questions

Is the map's idle event enough to capture safely?

No. idle reports that the engine has emptied its own frame queue, but the GPU may still be flushing the last draw and the browser compositor has typically presented only the previous frame at that instant. Combine idle with areTilesLoaded(), a camera-stillness check, and a count of consecutive no-repaint animation frames, and re-check the whole predicate on every idle and render.

Why does one requestAnimationFrame not guarantee the frame is painted?

A single rAF callback runs before the browser paints and presents the frame it is scheduled for, so resolving inside it reads the surface one presentation too early. Schedule a second rAF from inside the first — a double rAF — so your resolve runs on the frame after the one in flight, by which point the last draw has been presented. Counting N no-repaint frames is the more robust form of the same idea.

Can I use WebGL fences or timer queries to know the GPU is done?

Rarely usefully. Timer-query extensions are disabled in headless browsers as a timing side channel, so getExtension returns null. WebGL 2 sync objects work but prove completion of a fence you insert, not presentation of the engine’s frame — the compositor boundary that actually trips capture stays uncovered. Drive completion from the engine render loop and rAF counting instead, using gl.finish() as the only reliable GPU primitive on host-GPU runs.

← Back to Screenshot Capture, Sync & Comparison Logic