Handling Async Tile Loading

Asynchronous tile loading is the mechanism behind every slippy map’s progressive render — and the single largest source of flake in map visual regression suites. Tile requests, vector decode, sprite hydration, and WebGL compositing all run concurrently and off the main thread, so an automated screenshot fired on load or networkidle routinely captures a half-painted frame: placeholder tiles, missing glyphs, or a basemap that has arrived while the overlay has not. The result is intermittent diffs that pass on retry, eroded trust in the gate, and engineers re-running CI instead of reading it. This page narrows the capture problem to one question — how do you know every tile that belongs in this frame is decoded and painted before the shutter fires? — and gives a deterministic answer.

This page extends the synchronization stage of Screenshot Capture, Sync & Comparison Logic; it assumes the camera is already pinned by Viewport & Zoom Sync Strategies and focuses solely on tile hydration once the viewport is locked.

What “tile loaded” actually means

The naive signal — the browser’s networkidle — is wrong for maps because it reports the transport layer, not the render layer. A tile can finish downloading while its vector geometry is still being parsed on a worker, its glyphs are still rasterizing, and its triangles are still being uploaded to a GPU buffer. A tile is only “loaded” for comparison purposes when all four of these have completed and the frame containing it has been painted.

Web mapping engines model this lifecycle explicitly. In the MapLibre GL JS / Mapbox GL data model, each tile transitions through loading → loaded → reloading → errored, exposed through the data event (e.dataType === 'source', e.tile.state) and summarized by two synchronous predicates:

  • map.isSourceLoaded(id) — every requested tile for one source has resolved.
  • map.areTilesLoaded() — the same, aggregated across all sources in the current style.

The idle event is the engine’s own composite signal: it fires when no source is loading and no transition or animation is in flight and the last frame has been rendered. That makes idle necessary but not sufficient on its own, because it can fire transiently between two tile batches — for example when a fractional zoom triggers a second pyramid level after the first has settled. Reliable deterministic tile capture therefore combines idle with an explicit, externally tracked count of in-flight requests rather than trusting any single event.

The coordinate contract underneath all of this is the tiling scheme. Aligning your fixtures and assertions to the OGC Two Dimensional Tile Matrix Set standard (or the de-facto XYZ/WMTS {z}/{x}/{y} grid) means the set of tiles that should exist for a given viewport is computable in advance — which is what lets you wait for exactly those tiles and ignore speculative prefetch.

Synchronization architecture

A robust gate instruments the run in three phases, layered so each compensates for the blind spot of the one below it.

  1. Network interception and queue tracking. Intercept outgoing tile requests at the browser layer and maintain a live registry of pending coordinates keyed on the tile URL pattern. Each matching request increments a counter; each response (success or error) decrements it. This catches the transport layer that idle does not expose externally.
  2. Viewport and grid validation. Resolve which tiles the visible viewport actually requires at the locked zoom, and wait only for those. This prevents two failure classes: capturing while the engine is still prefetching off-screen tiles, and waiting forever for tiles a CDN never serves at the fringe of coverage. It is the same camera-stability contract enforced by Viewport & Zoom Sync Strategies, now applied to the tile grid.
  3. Post-render stabilization. After the network drains and idle fires, hold for a deterministic settle window — a small number of requestAnimationFrame ticks — to let GPU compositing, shader compilation, and text rasterization finish and the framebuffer stop mutating.
Tile lifecycle and the three layered synchronization gates A horizontal flow of five tile stages advances left to right: requested, downloading (transport), decode and parse on a worker thread, GPU upload, and painted (highlighted as the settled end state). Three gate bars sit below. The network counter spans the requested and downloading stages, incrementing per request and decrementing per response. The visible-grid check spans the requested through GPU-upload stages, waiting for exactly the computed visible-tile set. The requestAnimationFrame settle window spans GPU upload and paint, holding for two no-repaint frames so compositing and rasterization flush. Requested Downloading transport Decode + parse worker thread GPU upload Painted ✓ SYNCHRONIZATION GATES Network counter: ++ / −− per tile Visible-grid check: wait for exactly the required tile set rAF settle  2 no-repaint frames

Step-by-step: gating capture on full tile hydration

The following procedure is engine-agnostic at the network layer and uses GL-specific predicates where they exist. It assumes the camera has already been jumped to a serialized state and interaction handlers disabled.

  1. Intercept and count tile requests. Register a route handler that matches the tile media patterns (*.pbf, *.mvt, *.png, *.webp) before navigation, so no request escapes the counter.

    const pending = new Set();
    
    await context.route(/\.(pbf|mvt|png|webp)(\?|$)/, async (route) => {
      const url = route.request().url();
      pending.add(url);
      try {
        await route.continue();
      } finally {
        pending.delete(url);
      }
    });
    
  2. Resolve and lock the viewport. Navigate, then apply the serialized camera with jumpTo (never an animated flyTo, which would be captured mid-ease) and disable inertia so the grid stops moving.

    await page.goto(target, { waitUntil: 'domcontentloaded' });
    await page.evaluate((cam) => {
      const map = window.__testMap;
      map.jumpTo(cam);          // center, zoom, pitch, bearing — fixed precision
      map.stop();               // cancel any in-flight easing
    }, camera);
    
  3. Wait for the engine’s own tile predicates. Resolve a promise only when the map reports it is settled and every source’s tiles are loaded, re-checking on each idle because idle can fire more than once.

    await page.evaluate(() => new Promise((resolve) => {
      const map = window.__testMap;
      const settled = () =>
        !map.isMoving() && !map.isRotating() && map.areTilesLoaded();
      const check = () => { if (settled()) { map.off('idle', check); resolve(); } };
      map.on('idle', check);
      check();                  // handle the already-idle case
    }));
    
  4. Confirm the network counter has drained. Belt-and-braces against tiles the engine considers optional (prefetch) or assets outside its source model (sprites, fonts).

    await page.waitForFunction(() => window.__pendingTiles === 0, null,
      { timeout: 15000, polling: 100 });
    
  5. Hold a deterministic stabilization window. Wait two consecutive animation frames in which the canvas does not repaint, so GPU compositing and glyph rasterization have flushed.

    await page.evaluate(() => new Promise((resolve) => {
      let stable = 0;
      const tick = () => (++stable >= 2 ? resolve() : requestAnimationFrame(tick));
      requestAnimationFrame(tick);
    }));
    
  6. Capture the canvas, not the chrome. Screenshot the map element with animations frozen, so any residual CSS transition is held rather than caught mid-frame.

    const buffer = await page.locator('#map').screenshot({ animations: 'disabled' });
    

This layered gate is exactly the recipe expanded, with full fixture wiring, in How to wait for all map tiles to load before screenshot.

Gated tile capture sequence: runner, interceptor, server, and map engine Four lifelines from left to right — Test runner, Browser route interceptor, Tile server, and Map engine. Messages flow top to bottom: navigate and lock viewport (runner to interceptor); request visible z/x/y tiles (interceptor to server) with a note that pendingTiles increments per request; tile responses returned (server to interceptor) with a note that pendingTiles decrements per response; decode, style, composite (interceptor to map engine); idle event with queue drained returned (map engine to runner); a self-directed await of the requestAnimationFrame settle window on the runner; and finally capture screenshot (runner to interceptor). Test runner Route interceptor Tile server Map engine navigate & lock viewport request visible {z}/{x}/{y} tiles tile responses decode · style · composite idle event · queue drained capture screenshot await requestAnimationFrame settle pendingTiles ++ pendingTiles −−

Visible tiles versus prefetch

Background prefetching continues after the visible viewport is satisfied, so a counter that waits for every request to drain can hang on speculative tiles that may never be needed. Wait instead for exactly the grid the viewport covers. For a viewport of width and height device pixels at tile size and device pixel ratio , the visible tile count is bounded by:

The +1 on each axis accounts for tiles straddling the viewport edge. Computing up front lets the harness assert “all required tiles resolved” against a known target instead of waiting for an open-ended network to fall quiet — the difference between a 200 ms gate and a 15 s timeout on a map that prefetches aggressively.

Cross-browser and cross-environment considerations

Tile hydration timing and the resulting pixels diverge across engines and runners, so the gate must be hardened, not just correct on a laptop.

  • Chromium is the baseline for headless map testing: it exposes stable route interception and supports the --use-gl=angle --use-angle=swiftshader software backend that makes WebGL output deterministic across CI nodes regardless of host GPU.
  • WebKit (Playwright) lacks the same GL flags; force a consistent compositing path and expect subpixel label differences versus Chromium. Keep a separate baseline per engine rather than sharing one — the same divergence the cross-engine baseline matrix is built to track.
  • Firefox decodes vector tiles on a different worker schedule, so the idle/areTilesLoaded race in step 3 surfaces more often; the network-counter belt in step 4 matters most here.
  • Containerization. Pin the browser image (e.g. mcr.microsoft.com/playwright:vX-jammy) so bundled fonts and the GL stack are fixed. Disable HTTP caching on tile endpoints inside CI so a warm cache never lets a run skip the very hydration you are trying to measure, and serve tiles, sprites, and style JSON from local fixtures to remove network jitter entirely.
  • Fonts and sprites. Preload glyph PBFs and the sprite sheet before initializing the map; a late-arriving glyph triggers a relayout that repaints labels after idle and silently invalidates the frame.

Threshold & parameter reference table

Starting values for the gate’s timing knobs. Tune timeouts up for cold CDNs and DPR-2 retina baselines, down for local fixtures.

Parameter Recommended value Rationale
Tile request timeout 15000 ms Upper bound for a cold CDN per visible tile batch before failing the run
Network counter poll interval 100 ms Tight enough to catch a brief drain, cheap enough to avoid CPU churn
Idle re-check on every idle event idle can fire transiently between tile batches at fractional zoom
Stabilization frames 2 consecutive no-repaint rAF Lets GPU compositing and glyph rasterization flush
Device pixel ratio (DPR) 1 (CI), document 2 separately DPR-2 doubles tile count and changes anti-aliasing; never mix in one baseline
Visible-grid buffer +1 tile per axis Covers tiles straddling the viewport edge
GL backend swiftshader (software) Removes host-GPU variance across runners

Common pitfalls

Capture fires between two tile batches

Root cause: the harness resolved on the first idle, which fired after the base zoom level settled but before a fractional-zoom second pyramid level requested. Diagnose: log map.areTilesLoaded() and window.__pendingTiles at capture time; a false/non-zero pair on the failing run confirms it. Fix: re-check the settle predicate on every idle (step 3) and require the network counter to be zero (step 4) rather than resolving on the first event.

Gate hangs on speculative prefetch tiles

Root cause: the network counter waited for all requests to drain, including off-screen tiles the engine prefetches and may abandon. Diagnose: the run times out with a small, non-decreasing pending count of off-viewport coordinates. Fix: wait for the computed visible grid rather than a global quiet network, and treat aborted prefetch requests as resolved.

Labels shift one to two pixels between otherwise-identical runs

Root cause: a glyph or sprite arrived after idle, forcing a label relayout and repaint post-capture; or anti-aliasing differs because no software GL backend was pinned. Diagnose: diff two captures from the same runner with no code change — persistent edge noise on text points at rendering, not a regression. Fix: preload glyphs and sprites before map init, pin swiftshader, and absorb residual subpixel noise with Dynamic Threshold Configuration and the filters in Noise Reduction for Map Artifacts.

Tiles never arrive at the coverage fringe

Root cause: the viewport sits where the source has no data, so those {z}/{x}/{y} tiles 404 and the engine marks them errored — but a naive counter that only decrements on 2xx waits forever. Diagnose: inspect intercepted responses for 404/204 on edge coordinates. Fix: decrement the pending counter on any response status and trust areTilesLoaded(), which already accounts for the errored state.

Passes locally, flakes only in CI

Root cause: a warm local tile cache hid the hydration delay that a cold containerized runner exposes, or the host GPU rendered differently from the CI node. Diagnose: clear the cache and re-run locally; if it now flakes, the gate — not CI — is the problem. Fix: serve tiles from local fixtures, disable HTTP caching for tile endpoints, and pin the browser image and software GL backend so local and CI render identically. This is the same network-determinism concern covered for capturing consistent map states across network conditions.

Frequently asked questions

Is networkidle enough to wait for map tiles?

No. networkidle reports the transport layer only — a tile can finish downloading while its vector geometry is still parsing on a worker and its triangles are not yet uploaded to the GPU. Gate on the engine’s idle event plus areTilesLoaded() and an explicit in-flight request counter, then hold a short rAF stabilization window.

Why does my map test pass on retry but fail intermittently?

The capture fired before all tiles hydrated because the idle event raced between two tile batches. Re-check areTilesLoaded() on every idle and require your intercepted-request counter to reach zero before capturing, and serve tiles from local fixtures to remove network variance.

How do I avoid waiting forever on background prefetch?

Compute the visible tile grid for the locked viewport and wait only for those {z}/{x}/{y} coordinates, rather than for a globally quiet network. Speculative off-screen tiles can be abandoned by the engine and will otherwise keep the counter from ever draining.

← Back to Screenshot Capture, Sync & Comparison Logic