How to wait for all map tiles to load before screenshot
Your test fires page.screenshot() and the saved frame shows a half-painted basemap: a few grey placeholder tiles, a missing label cluster, or an overlay that arrived a frame too late. On retry it passes. That intermittence is the signature of capturing before the tile queue has drained — the shutter beat the renderer. This page gives a concrete, copy-ready procedure for holding capture until every tile that belongs in the locked viewport is downloaded, decoded, composited by the GPU, and styled, so the same pixels come out on every run.
This is one task inside Handling Async Tile Loading, the guide that explains why transport-level signals lie about render state. It sits under the broader pipeline described in Screenshot Capture, Sync & Comparison Logic, and it assumes the camera is already pinned per Viewport & Zoom Sync Strategies — an unlocked camera changes which tiles are in frame and makes any wait meaningless.
Prerequisites
Step-by-step procedure
1. Lock the viewport so the tile set is deterministic
Before you can wait for “all” tiles, the set of tiles in frame must be fixed. Fractional zoom forces the renderer to interpolate between two discrete levels, and pan inertia keeps requesting new tiles after you think the camera has settled — both make the in-flight count never reach a stable zero. Pin an integer zoom, disable easing, and turn off interaction.
const mapConfig = {
center: [-122.4194, 37.7749], // Exact WGS84 coordinates
zoom: 12, // Integer zoom only
bearing: 0,
pitch: 0,
interactive: false, // Disable user interaction during test
fadeDuration: 0, // Disable CSS/JS fade transitions
crossSourceCollisions: false // Prevent label collision jitter
};
With the camera frozen, every tile request targets a predictable grid coordinate, which is the precondition for counting requests to completion.
2. Intercept tile requests and maintain an in-flight counter
Route the tile URL patterns and increment a counter when a request starts, decrement when its response resolves. Keep the counter in the test (Node) scope — not the browser context — so you can poll it directly. Scope the route narrowly (vector .pbf, raster .png/.webp) so unrelated requests do not hold the gate open.
let pendingTiles = 0;
await page.route('**/*.pbf', async (route) => {
pendingTiles++;
const response = await route.fetch();
pendingTiles--;
await route.fulfill({ response });
});
3. Gate on both the network drain and the native idle event
The network counter alone is not enough: it can hit zero while the renderer is still compositing the final frame, or the library can report idle while workers are still parsing geometry. Combine the two into a dual-verification gate. Poll the counter in the Node scope, then await the library’s idle event — the point at which it reports no further frames are needed for the current camera.
// Wait for both network drain and map idle state. The counter lives in the test
// (Node) scope, so poll it here rather than inside page.waitForFunction, which
// runs in the browser context where `pendingTiles` does not exist.
while (pendingTiles > 0) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
await page.evaluate(() => new Promise((resolve) => window.mapInstance.once('idle', resolve)));
4. Confirm WebGL render completion after idle
idle fires when the library has queued its last frame, but WebGL draws asynchronously: gl.drawElements() returns immediately while the GPU is still flushing the buffer. Chain a render-complete check that resolves only once the source reports loaded and the layers exist, then yield one requestAnimationFrame so the compositor finishes its final pass.
function waitForRenderComplete(map) {
return new Promise((resolve) => {
const check = () => {
if (map.isSourceLoaded('composite') && map.getLayer('background')) {
map.off('render', check);
resolve();
}
};
map.on('render', check);
});
}
Prefer this render-loop signal over a fixed setTimeout. Browser security policies usually block direct GPU fence queries (gl.getQueryObject() in WebGL 2.0) in headless mode, so the library’s own render loop is the most portable cross-browser proof that the frame buffer is flushed.
5. Verify data-layer and source readiness
Vector tiles, clustering, and data-driven label placement run after the tile bytes arrive — often on a Web Worker. Capturing during this window yields missing labels or partially computed clusters. Track sourcedata until the relevant source reports it is loaded, and for GeoJSON overlays wait on the source load event. Enforce a layer dependency order — basemap tiles → vector overlays → dynamic markers → UI chrome — so style evaluation completes before rasterization and z-index conflicts cannot leak into the frame.
await page.evaluate(() => new Promise((resolve) => {
const map = window.mapInstance;
const ready = () => map.isSourceLoaded('composite') && map.areTilesLoaded();
if (ready()) return resolve();
map.on('sourcedata', function handler() {
if (ready()) {
map.off('sourcedata', handler);
resolve();
}
});
}));
6. Fire the screenshot immediately after the final gate
Once the counter is zero, idle has fired, the render loop has stabilized, and the source reports loaded, capture without any further delay — a stray setTimeout only invites drift from background repaints or service-worker cache updates. Clip to the locked viewport so unrelated chrome never enters the comparison.
const screenshot = await page.screenshot({
clip: { x: 0, y: 0, width: 1024, height: 768 },
fullPage: false,
omitBackground: true
});
Hand the image straight into the diff stage. A structural comparator — Structural Similarity Index (SSIM) or perceptual hashing — tolerates the minor anti-aliasing variance covered in Dynamic Threshold Configuration while still flagging the tile gaps this whole procedure exists to prevent.
Verification
Confirm the gate actually holds before trusting it in CI:
A correct setup gives a green stability run and a clean full-size frame under throttling; if a fast run passes but a slow one fails, capture is racing the renderer rather than waiting on it.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Frame shows grey placeholder tiles at the edges | Counter hit zero before fringe coverage tiles were requested | Add areTilesLoaded() to the gate (step 5) and confirm the route pattern matches every tile extension in use |
| Gate hangs forever | A non-tile request matches the route, or the renderer keeps emitting frames under a fractional zoom | Narrow the route pattern in step 2 and pin an integer zoom per Viewport & Zoom Sync Strategies |
| Passes locally, flakes only in CI | Host GPU composites differently from the runner, so the final frame differs by sub-pixels | Pin a software GL backend (swiftshader) and lean on Noise Reduction for Map Artifacts for residual variance |
Frequently asked questions
Is networkidle enough to wait for map tiles?
No. networkidle reports the transport layer — that bytes stopped arriving — but says nothing about vector decode, glyph rasterization, or GPU compositing, all of which finish after the last response. Gate on the in-flight tile counter plus the library’s idle and areTilesLoaded() signals instead, as in steps 3–5.
Why does my test pass on retry but fail intermittently?
Because capture is racing the renderer. On a fast run the GPU happens to finish before the shutter; on a slow one it does not, and you capture a half-painted frame. Replace every implicit timeout with the queue-drain plus render-complete gate so the wait scales with actual render time rather than a guessed delay.
How do I avoid waiting forever on background prefetch tiles?
Speculative prefetch outside the viewport can keep the counter above zero indefinitely. Scope the route to the tiles in the locked viewport, or disable prefetch in the renderer config, and rely on areTilesLoaded() — which reports only the tiles needed for the current camera — rather than a raw global request count.
Can I just add a fixed setTimeout before capture?
A fixed delay is the original problem in disguise: too short and it flakes, too long and it slows every run. Use the render-complete promise from step 4, which resolves exactly when the source is loaded and the frame is composited, so the wait is as short as it can safely be and never shorter.
Related
- Up to the parent cluster Handling Async Tile Loading, and the grandparent pipeline Screenshot Capture, Sync & Comparison Logic.
- Configuring pixel diff thresholds for anti-aliased map labels
- Reducing false positives from WebGL rendering artifacts