Waiting for Vector Tile Worker Completion
A vector tile arrives as a compressed protobuf and becomes pixels only after a worker decodes it, simplifies geometry for the current zoom, lays out symbols and resolves collisions. On the main thread all of that is invisible: the fetch completed, the network went quiet, and any heuristic watching requests concludes the map is ready. On a dense urban tile the worker stage can take longer than the fetch did, which is why network-idle capture produces a frame with roads but no labels — and why the same gate behaves perfectly on a sparse scene and badly on a busy one.
This is a task within Handling Async Tile Loading, under Screenshot Capture, Sync & Comparison Logic. It adds one clause to the composite predicate built in WebGL Idle & Render-Completion Detection.
Prerequisites
The two completion points
The distinction matters more on a fixtured suite than on a live one, which is counterintuitive. When tiles come from the network, the fetch dominates and the worker stage hides inside it. When tiles are served from memory in a millisecond, the worker stage becomes the entire load time — so a suite that was fine before fixtures were introduced can start capturing early immediately after, and the change looks like it made things worse.
Observing the worker stage
The observable signal is the engine’s own data event, filtered to tile content:
await context.addInitScript(() => {
window.addEventListener('map:created', (event) => {
const map = event.detail;
let applied = 0;
map.on('data', (e) => {
if (e.dataType === 'source' && e.sourceDataType === 'content') applied += 1;
});
window.__tileContentApplied = () => applied;
});
});
sourceDataType === 'content' is the important filter: the engine emits data for metadata, for visibility changes and for tile content, and only the last of those means a worker finished a tile and the result was applied.
The predicate then requires the counter to stop rising rather than to reach a particular value, because the total depends on the camera, the prefetch ring and whether any tiles were already cached:
let last = -1, stable = 0;
const workerSettled = () => {
const now = window.__tileContentApplied();
if (now !== last) { last = now; stable = 0; return false; }
return ++stable >= 3;
};
Three stable frames rather than two, here, because worker results arrive in batches with gaps between them — two frames is often enough to land inside a gap.
Folding it into the capture predicate
const settled = () =>
networkDrained() &&
workerSettled() &&
map.areTilesLoaded() &&
!map.isMoving() && !map.isRotating() &&
quietFrames >= 2;
The clause is cheap — a counter comparison — and it closes the specific failure that survives every other gate: a frame in which the geometry has been applied and the symbols have not, which renders as a map with roads and buildings but noticeably fewer labels than the baseline. That failure is particularly nasty because it looks exactly like a label-collision regression, and teams have spent days investigating the style before finding it in the harness.
Why this failure is so often misdiagnosed
A frame missing some of its labels looks exactly like a cartographic regression, and that resemblance costs teams more time than the bug itself.
The symptom is a diff concentrated in label regions, with the candidate showing fewer labels than the baseline in the denser parts of the frame. Every plausible first hypothesis is about the style: a collision priority changed, a text-allow-overlap was removed, a symbol-sort-key was edited, a font’s metrics shifted so labels now collide where they previously fitted. All of those produce the same picture, and all of them are worth several hours of investigation before anyone suspects the harness.
Two observations separate the harness cause from the style cause quickly, and both are cheap.
Does the label count vary between runs of the same commit? A style regression is deterministic — the same style and the same data produce the same collisions every time. A worker-timing capture is not: the number of labels present depends on how many symbol batches landed before the shutter, which varies with machine load. Running the same test ten times and counting rendered symbol features answers the question in a minute.
Does throttling the CPU change the count? If halving the available CPU reduces the number of labels in the capture, the capture is racing the worker. A style regression is entirely indifferent to CPU speed.
Both checks are worth building into the suite as a diagnostic command rather than remembering them, because the situation recurs — every new label-dense scenario is a fresh opportunity for a predicate that was adequate on lighter scenes to become inadequate, and the first symptom is always a diff that looks like somebody changed the cartography.
Verification
Confirm the procedure worked before wiring it into a blocking gate:
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Labels are missing only on the busiest scenes | The capture fired after geometry was applied but before symbol layout finished in the worker | Add the content-event stability clause; geometry and symbols are applied in separate batches |
| The counter never stabilises | Something is still requesting tiles — usually a prefetch ring or an animated source | Lock the camera and freeze animated sources first; a counter that rises forever is reporting a real, ongoing load |
| The suite started capturing early after fixtures were introduced | The fetch stage no longer dominates, so the worker stage is now the whole load time | This is expected: the fixture change exposed a pre-existing gap in the predicate rather than causing one |
Frequently asked questions
Why not just wait for the idle event?
idle means the engine has no more frames scheduled for the current camera and data, and the engine can reach that state while a worker batch is still in flight for a source it currently considers satisfied. It fires again when the batch lands, which is why the composite predicate re-evaluates on every idle rather than resolving on the first. The worker clause makes the same guarantee explicit rather than relying on a second idle arriving.
Does this apply to raster tile maps?
Much less. A raster tile is decoded by the browser’s image pipeline rather than by a worker doing geometry and symbol work, so the gap between arrival and readiness is small and largely covered by the engine’s own signals. The clause is cheap enough to keep in a shared predicate regardless, and it costs nothing on a scene that has no vector sources.
How many stable frames are enough?
Three is a good default because worker results arrive in bursts with idle gaps between them, and two frames frequently lands inside a gap. Raising it further costs a few frames of latency per capture and buys progressively less; if three is not enough on a particular scene, the more likely explanation is that something is still requesting tiles rather than that the number needs to be five.
Can the worker stage be made faster instead?
Somewhat — simplifying the style, reducing the number of symbol layers, and lowering maxzoom on heavy sources all cut worker time, and all change what is rendered. That makes them product decisions rather than test ones. The gate’s job is to wait correctly for whatever the application does, not to constrain the application into being easier to photograph.
Related
- Up to Handling Async Tile Loading, and the section Screenshot Capture, Sync & Comparison Logic.
- WebGL Idle & Render-Completion Detection — the frame-level clauses this one joins.
- How to Wait for All Map Tiles to Load Before a Screenshot — the network-side clause.
- Intercepting Vector Tile Requests with Playwright route — the fixture layer that makes this stage the dominant one.