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

Where a tile finishes on the main thread and where it finishes in the worker One tile is traced through both threads. On the main thread the request is issued, the response arrives, and the bytes are handed to a worker — at which point the main thread considers the fetch complete, which is what a network-idle heuristic observes. Inside the worker the payload is then decoded from protobuf, geometry is simplified, symbols are laid out and collision is resolved, and only then is a ready message posted back. The gap between the two completion points is where a capture taken on network idle lands, and it is wider for a dense tile than a sparse one, so the same gate behaves differently on different scenes. The network finishes long before the tile does main thread request response post to worker ← network idle fires here worker decode simplify symbols ready the gap is wider for a dense tile, so the same gate behaves differently per scene

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

Signals that prove worker completion, and what each one misses Three candidate signals are compared. Counting network responses proves only that bytes arrived, and misses all worker processing. Polling areTilesLoaded proves the engine believes its sources are satisfied, which covers decoding but can be true transiently between two batches of work. Counting the engine data events with the sourceDataType of content, and requiring the count to stop rising across consecutive frames, proves that no further tile content has been applied — which is the property a capture actually needs. A caption notes that the third signal is the only one that distinguishes finished from momentarily quiet. Only the third distinguishes finished from momentarily quiet network response count proves bytes arrived · misses every stage of worker processing areTilesLoaded() proves the sources are satisfied · can be true between two batches content data events, count stable across frames proves no further tile content was applied — the property a capture needs

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

Worker completion as one more clause in the composite predicate The capture predicate is drawn as four clauses that must hold on the same frame. Network responses complete covers transport. Worker content events stable covers decode, simplification and symbol layout. Camera still covers easing and inertia. Two quiet frames cover compositor presentation. An arrow shows all four converging on a single shutter event. A note explains that the worker clause is the one most often missing, because network idle and engine idle both appear to cover it and neither does — the engine can report idle while a worker batch is still in flight for a source it considers satisfied. Four clauses, one frame network complete worker content stable camera still two quiet frames shutter the worker clause is the one most often missing, because two other signals appear to cover it
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.