Setting Up WebGPU Device Polling for GIS Apps

The specific sub-problem here is retry orchestration for adapter and device acquisition when the GPU stack is not yet ready. A GIS application that calls navigator.gpu.requestAdapter() once, on the first frame, races the browser’s own GPU process startup, driver paging, and — on shared enterprise hardware — power-state transitions on the discrete adapter. The single call returns null not because WebGPU is unsupported but because it was asked too early, and the map silently never renders. Robust device polling replaces that one shot with a bounded, backed-off loop that distinguishes “not ready yet” (retry) from “not available here” (route to a fallback), then gates every later coordinate buffer allocation and shader compilation behind a single resolved device handle. This page is the readiness layer underneath the broader WebGPU Architecture for Spatial Visualization stack: nothing downstream — no compute dispatch, no tile render pass — may run until this loop resolves.

WebGPU device polling loop with backed-off retry and fallback exit A state machine for acquiring a WebGPU device under retry. START flows into requestAdapter, which is raced against a 2000 ms deadline. A gate then asks whether the adapter resolved non-null before the deadline. If yes, the flow continues down to requestDevice, called with a descriptor whose features are intersected with adapter capabilities and whose limits are clamped. A second gate asks whether the device resolved without a rejection or device.lost event; if yes the flow reaches the READY exit state, where the queue and buffers bind. The null-adapter branch goes left to an attempt-less-than-maxRetries gate: while attempts remain it passes through a backoff box that waits 500 times two to the power of the attempt number, plus or minus 25 percent jitter, then retries requestAdapter; once retries are exhausted it routes to the FALLBACK exit. A rejected or lost device also routes to FALLBACK. The two exit states are highlighted distinctly: READY in green and FALLBACK, a WebGL2 or CPU path, in coral. DEVICE POLLING LOOP · RETRY UNTIL READY OR FALL BACK yes yes null yes retry rejected / lost retries exhausted START requestAdapter({ powerPreference }) raced against a 2000 ms deadline adapter resolved? non-null before the deadline? requestDevice(descriptor) features ∩ caps · limits clamped device resolved? no rejection or device.lost? attempt < maxRetries? backoff 500·2^n ms ±25% READY queue + buffers bind FALLBACK WebGL2 / CPU path

Runnable reference implementation

The loop below acquires an adapter under retry, negotiates a device against the adapter’s real capabilities, and resolves a single readiness promise the rest of the application can await. It is written in TypeScript so the descriptor shapes stay type-checked against @webgpu/types; the spatial-data-specific choices are called out inline.

typescript
interface GpuReadiness {
  device: GPUDevice;
  adapter: GPUAdapter;
  hasTimestampQuery: boolean;
}

// Race requestAdapter against a deadline: a hung GPU process should fail the
// attempt and trigger backoff, not block the first paint indefinitely.
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | null> {
  return Promise.race([
    p,
    new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
  ]);
}

async function acquireAdapter(maxRetries = 3): Promise<GPUAdapter | null> {
  if (!navigator.gpu) return null; // API absent — no amount of retrying helps.

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    // high-performance prefers the discrete GPU: integrated parts rarely have
    // the storage-buffer bandwidth for dense point clouds or vector-tile meshes.
    const adapter = await withTimeout(
      navigator.gpu.requestAdapter({ powerPreference: "high-performance" }),
      2000,
    );
    if (adapter) return adapter;

    // Exponential backoff with jitter: 500ms, 1000ms, 2000ms (+/- 25%).
    // Jitter de-synchronises retries across tabs sharing one GPU process.
    const base = 500 * 2 ** attempt;
    const jitter = base * 0.25 * (Math.random() * 2 - 1);
    await new Promise((r) => setTimeout(r, base + jitter));
  }
  return null;
}

async function pollForDevice(maxRetries = 3): Promise<GpuReadiness | null> {
  const adapter = await acquireAdapter(maxRetries);
  if (!adapter) return null; // caller routes to the WebGL2 fallback path.

  // Only request what the adapter actually advertises — an unsupported feature
  // makes requestDevice() reject with OperationError, not degrade gracefully.
  const wanted = ["timestamp-query", "float32-filterable"] as const;
  const requiredFeatures = wanted.filter((f) => adapter.features.has(f));

  // Clamp every limit to the adapter ceiling. maxBufferSize tops out at 2GB
  // (2^31) on most current hardware, never the 4GB a naive default assumes.
  const requiredLimits: Record<string, number> = {
    maxBufferSize: Math.min(adapter.limits.maxBufferSize, 512 * 1024 * 1024),
    maxStorageBufferBindingSize: Math.min(
      adapter.limits.maxStorageBufferBindingSize,
      256 * 1024 * 1024,
    ),
  };

  let device: GPUDevice;
  try {
    device = await adapter.requestDevice({ requiredFeatures, requiredLimits });
  } catch (err) {
    console.error("requestDevice rejected:", (err as Error).message);
    return null; // treat a rejected device like a null adapter: fall back.
  }

  // Surface validation failures that would otherwise fail silently in prod:
  // a bad bind-group layout or oversized buffer throws here, not at the API call.
  device.addEventListener("uncapturederror", (event) => {
    const e = event as GPUUncapturedErrorEvent;
    console.error("WebGPU uncaptured error:", e.error.message);
  });

  // Device loss (driver timeout, VRAM eviction, tab backgrounding) is recoverable
  // only by re-running the whole poll — a lost device cannot be reused.
  device.lost.then((info) => {
    console.warn(`WebGPU device lost (${info.reason}): ${info.message}`);
    if (info.reason !== "destroyed") void pollForDevice(maxRetries);
  });

  return {
    device,
    adapter,
    hasTimestampQuery: requiredFeatures.includes("timestamp-query"),
  };
}

// Single readiness gate the rest of the app awaits before touching the GPU.
export const gpuReady: Promise<GpuReadiness | null> = pollForDevice();

A small but load-bearing detail: requestAdapter() has no featureLevel option in the WebGPU specification. Use powerPreference to bias adapter selection; everything else is negotiated after the adapter resolves, through requiredFeatures and requiredLimits. Once gpuReady resolves to a non-null value, the queue is configured and the device can feed pipeline layout compilation — the point at which negotiated limits must already match your bind-group allocations and workgroup memory ceilings.

Parameter and configuration reference

Every tunable value in the loop above, with guidance for geospatial workloads:

Parameter Default Spatial-workload guidance
maxRetries 3 Three attempts span ~3.5s of backoff — enough for cold driver paging without leaving the user on a blank map. Raise to 4–5 only on known-slow enterprise fleets.
withTimeout deadline 2000 ms Caps a single hung requestAdapter(). Keep below your first-paint budget so a stalled GPU process triggers backoff instead of blocking the basemap.
backoff base 500 * 2 ** attempt 500/1000/2000 ms. Geometric growth lets a slow-loading discrete GPU settle between attempts; linear retries hammer a busy GPU process.
backoff jitter ±25% De-correlates retries across multiple tabs or split-screen map views sharing one adapter, avoiding synchronised retry storms.
powerPreference "high-performance" Prefers the discrete adapter for dense point clouds and large vector-tile meshes. Use "low-power" only for static, low-zoom overview maps on laptops.
requiredFeatures timestamp-query, float32-filterable timestamp-query enables frame profiling; float32-filterable gives precise coordinate interpolation on float textures. Always intersect with adapter.features first.
maxBufferSize clamp 512 MiB Per-buffer ceiling for tile vertex/index pools. Hardware caps at 2GB (2^31); request only what one chunk needs so device loss does not evict the whole scene.
maxStorageBufferBindingSize clamp 256 MiB Bounds a single storage binding (e.g. one LiDAR tile’s points). Drives the chunk size the backend must pre-split coordinate arrays into.

The maxStorageBufferBindingSize you settle on is not just a frontend number — it is the contract the data layer must serialize against, covered in the memory alignment rules for spatial buffers. For tuning these ceilings against a concrete large payload, see How to Configure WebGPU Adapter Limits for Large GeoJSON.

Failure modes specific to device polling

  • null adapter that never recovers. Cause: navigator.gpu is present but no conformant adapter is granted — headless CI, blocklisted drivers, or locked-down enterprise GPU policy. Detection: the loop exhausts maxRetries and returns null. Fix: this is a permanent “not available here”, not a timing issue — route the session to the fallback routing path so the user still gets a map, and cache the negative result so the next visit does not re-poll.
  • OperationError from requestDevice(). Cause: a requested feature is absent from adapter.features, or a requested limit exceeds adapter.limits. Detection: the try/catch around requestDevice fires. Fix: filter features and clamp limits before the call (as the reference does); log the requested-versus-available intersection so you can see which feature was over-requested.
  • Retry storm across tabs. Cause: several map views sharing one GPU process all back off on the same fixed schedule and re-request in lockstep, prolonging contention. Detection: adapter acquisition succeeds only after the final attempt across many sessions. Fix: the ±25% jitter on the backoff base; without it, synchronised retries defeat the backoff entirely.
  • device.lost after a successful poll. Cause: driver watchdog timeout on a long compute pass, VRAM eviction under a too-aggressive buffer budget, or the tab being backgrounded. Detection: the device.lost promise resolves with a reason. Fix: a lost device is unusable — re-run the full pollForDevice() sequence (skip re-polling only when reason === "destroyed", which means you tore it down deliberately).

Backend / Python interop note

The clamped maxStorageBufferBindingSize decided here is the chunk boundary your Python data layer must respect. A backend that serializes a continental coordinate array as one blob will produce a payload the polled device refuses to bind. Pre-split server-side so each chunk fits the negotiated ceiling, and emit interleaved float32 arrays with explicit stride metadata so the frontend maps buffer offsets straight to storage bindings with no runtime realignment:

python
import numpy as np
import pyarrow as pa

MAX_STORAGE_BINDING = 256 * 1024 * 1024  # mirror the frontend clamp exactly

def chunk_coords(xy: np.ndarray) -> list[pa.Buffer]:
    """Split an (N, 2) float32 coordinate array into GPU-bindable chunks."""
    xy = np.ascontiguousarray(xy, dtype=np.float32)  # interleaved x,y,x,y...
    stride = xy.dtype.itemsize * xy.shape[1]         # 8 bytes per vertex
    rows_per_chunk = MAX_STORAGE_BINDING // stride
    return [
        pa.py_buffer(xy[i : i + rows_per_chunk].tobytes())
        for i in range(0, len(xy), rows_per_chunk)
    ]

Carry the stride and per-chunk row count in the response (GeoParquet metadata or an Arrow schema field) so the frontend allocates each storage buffer at exactly the size the polled device negotiated — keeping the CPU- and GPU-side layouts in lockstep.

Up: Initializing WebGPU Devices for GIS Workloads