Capturing consistent map states across network conditions

Your map snapshot test passes on a fast office connection and fails the moment CI runs it behind a throttled 3G profile — not because the UI regressed, but because the capture fired while tiles were still trickling in over a slow link. Under emulated latency and constrained throughput, map SDKs trigger exponential backoff, stitch tiles in a different order, and defer vector style evaluation until well after their load and idle events claim the map is ready. The result is a baseline that encodes a half-rendered frame. This page walks through treating the network profile as a controlled input variable — applying it deterministically at the browser engine level, draining every in-flight tile request, and gating capture on a multi-condition lock so the same locked viewport produces the same pixels whether it loaded over fibre or a simulated satellite hop.

This task is one concrete application of Viewport & Zoom Sync Strategies, the parent reference that explains how to pin camera state to byte-stable values. It assumes the broader deterministic machinery of Screenshot Capture, Sync & Comparison Logic is already in place — this guide changes only how you stabilize the network dimension of capture, not how frames are compared.

Prerequisites

Step-by-step procedure

1. Open a CDP session and disable the cache

Network emulation must be applied at the browser engine level, not through OS-level traffic shaping, so the profile is reproducible inside any containerized runner regardless of host networking. Acquire a CDP session against the page and enable the Network domain with caching off, so every tile request crosses the emulated link.

const client = await page.context().newCDPSession(page);
await client.send("Network.enable");
await client.send("Network.setCacheDisabled", { cacheDisabled: true });

2. Apply the network profile as a controlled variable

Configure Network.emulateNetworkConditions with explicit latency and throughput so the profile is a named, version-controlled fixture rather than ambient noise. Throughput is bytes per second — convert from the advertised bitrate. A representative slow-3G profile:

const PROFILES = {
  SLOW_3G: {
    offline: false,
    latency: 150,                              // round-trip ms
    downloadThroughput: (1.5 * 1024 * 1024) / 8, // 1.5 Mbps -> bytes/s
    uploadThroughput: (750 * 1024) / 8,          // 750 Kbps -> bytes/s
  },
};
await client.send("Network.emulateNetworkConditions", PROFILES.SLOW_3G);

3. Track in-flight tile requests with a CDP interceptor

The map SDK’s idle event fires prematurely under latency, so do not trust it alone. Maintain a counter in the Node test scope that increments on Network.requestWillBeSent for tile URLs and decrements on Network.loadingFinished or Network.loadingFailed. When the counter reaches zero, the network layer is quiescent.

let pendingTiles = 0;
const tileRe = /\.(pbf|mvt|png|webp|jpg)(\?|$)/i;
const inFlight = new Set();

client.on("Network.requestWillBeSent", (e) => {
  if (tileRe.test(e.request.url)) { inFlight.add(e.requestId); pendingTiles++; }
});
const settle = (e) => {
  if (inFlight.delete(e.requestId)) pendingTiles--;
};
client.on("Network.loadingFinished", settle);
client.on("Network.loadingFailed", settle);

4. Pin the camera before the tiles start arriving

Force the map to its known coordinate state with jumpTo() rather than flyTo() or easeTo(), eliminating frame-interpolation artifacts that vary with hardware refresh rate. Pin the device pixel ratio at the context level so tile-resolution requests are identical everywhere — window.devicePixelRatio is read-only and cannot be reassigned in page script.

await page.evaluate(({ lon, lat, z }) => {
  window.__MAP__.jumpTo({ center: [lon, lat], zoom: z, pitch: 0, bearing: 0 });
}, { lon: -122.4194, lat: 37.7749, z: 12 });

Launch the browser with --force-device-scale-factor=1 (or set deviceScaleFactor: 1 in the context) so a throttled run never silently requests @2x tiles a fast run would not.

5. Gate capture on a triple-condition lock

Under emulation the only safe trigger is the conjunction of three conditions: the SDK reports its tiles loaded, every active source is loaded, and the CDP interceptor queue is empty. Poll all three, then yield one animation frame so the compositor flushes the final GPU pass before reading the framebuffer.

async function awaitStableMap(page) {
  await page.waitForFunction(() => {
    const m = window.__MAP__;
    return m.areTilesLoaded() &&
           m.getStyle().sources &&
           Object.keys(m.getStyle().sources).every((id) => m.isSourceLoaded(id));
  });
  while (pendingTiles > 0) {                    // Node-scope counter from step 3
    await new Promise((r) => setTimeout(r, 50));
  }
  await page.evaluate(() => new Promise(requestAnimationFrame));
}

6. Capture from surface and store the profile in the artifact

Capture with fromSurface: true so the browser flushes the GPU command queue and reads the final framebuffer rather than a stale composited layer. Record which network profile produced the frame so a later diff failure can be correlated with the link, not mistaken for a UI regression.

await awaitStableMap(page);
const { data } = await client.send("Page.captureScreenshot", {
  format: "png",
  fromSurface: true,
  clip: { x: 0, y: 0, width: 1024, height: 768, scale: 1 },
});
const meta = { profile: "SLOW_3G", retries: 0, tiles: inFlight.size };

A consistent network state is worthless if the underlying tiles drift. Pin the source endpoints and verify a manifest checksum before each run so an upstream tile or style update cannot masquerade as a network-induced diff. The baseline-side contract for this lives in Baseline Management for Tile Servers.

network_cases:
  SLOW_3G:    { latency_ms: 150, down_kbps: 1500, up_kbps: 750 }
  LTE:        { latency_ms: 40,  down_kbps: 12000, up_kbps: 5000 }
  SATELLITE:  { latency_ms: 600, down_kbps: 1000, up_kbps: 256 }
manifest_sha256: 6f1c9e0b4a...   # fail the run if the tile manifest moves
One capture under network emulation: profile and camera lock, two link speeds, a triple gate, then the framebuffer read A left-to-right pipeline. Apply network profile with cache disabled feeds jumpTo camera lock, which fans out to two lanes — a throttled link whose tiles trickle in with backoff retries, and a fast link whose tiles arrive sooner. Both lanes converge on a triple gate requiring areTilesLoaded, every source loaded, and an interceptor queue drained to zero, joined by AND. A false result loops back to re-poll after a fifty millisecond wait; all three true advances to a requestAnimationFrame compositor flush and then captureScreenshot with fromSurface true. Both link speeds exit at the same locked viewport and yield identical pixels. any condition false → re-poll, wait 50 ms all three true Apply profile cache disabled jumpTo camera lock center · zoom · DPR Throttled link tiles trickle · backoff retry Fast link tiles arrive sooner Triple gate all must hold (AND) areTilesLoaded() AND every source loaded AND interceptor queue == 0 requestAnimationFrame compositor flush captureScreenshot fromSurface: true both links → same locked viewport identical pixels

Verification

Confirm the network dimension is actually neutralized before trusting it in CI:

A correctly stabilized test yields identical pixels across every network case and still fails the injected-regression run; if the profiles disagree with each other, the lock is incomplete, and if the injected run passes, capture is firing on a frame the gate should have rejected.

Troubleshooting

Symptom Likely cause Fix
Diff passes on fast link, fails under throttling Capture gated on the SDK idle/load event alone, which fires before slow tiles finish Add the CDP interceptor queue and areTilesLoaded() to the triple gate in step 5 so capture waits for the real drain
Frames differ between two equal-speed runs HTTP cache served some tiles from memory on one run, the link on the other Set Network.setCacheDisabled: true and pin deviceScaleFactor: 1 so every run requests the same tiles at the same resolution
Random tiles missing only under high latency Backoff retries still in flight at capture; pendingTiles never reached zero Decrement the counter on loadingFailed as well as loadingFinished, and confirm requestAnimationFrame runs after the queue drains — see Handling Async Tile Loading

Frequently asked questions

Why emulate the network in the browser instead of shaping traffic with tc or a proxy?

OS-level traffic shaping is host-dependent and leaks into every other process on the runner, so two CI machines rarely reproduce the same profile. Applying Network.emulateNetworkConditions through CDP scopes the throttle to the page under test and makes it a portable, version-controlled fixture — the same profile behaves identically in a laptop dev loop and a containerized runner.

If I drain every tile request, do I still need the SDK's areTilesLoaded check?

Yes. An empty network queue means the bytes arrived, but the renderer still has to decode, upload textures, and evaluate vector style before the frame is correct. Combining areTilesLoaded() plus per-source isSourceLoaded() with the network drain is what closes the gap between “responses finished” and “frame composited” — gate on all three, as covered by Handling Async Tile Loading.

Should each network profile have its own baseline, or share one?

Share one. The entire point of stabilizing the link is that a correctly captured frame is independent of how fast it loaded, so all profiles must diff clean against a single baseline. If you find yourself needing per-profile baselines, the gate is letting load order bleed into the pixels and needs tightening, not branching.

How do I tell a real CDN/network regression apart from cartographic noise?

Record the profile, retry count, and final queue state in the capture artifact, then correlate them with the diff result. A frame that fails only under one profile points at backoff or a slow edge; a frame that fails across all profiles points at genuine UI change or label/anti-alias noise handled in Noise Reduction for Map Artifacts.