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.
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.
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
nulladapter that never recovers. Cause:navigator.gpuis present but no conformant adapter is granted — headless CI, blocklisted drivers, or locked-down enterprise GPU policy. Detection: the loop exhaustsmaxRetriesand returnsnull. 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.OperationErrorfromrequestDevice(). Cause: a requested feature is absent fromadapter.features, or a requested limit exceedsadapter.limits. Detection: thetry/catcharoundrequestDevicefires. 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.lostafter 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: thedevice.lostpromise resolves with areason. Fix: a lost device is unusable — re-run the fullpollForDevice()sequence (skip re-polling only whenreason === "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:
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.
Related
- Initializing WebGPU Devices for GIS Workloads — the full device-handshake walkthrough this readiness loop sits inside.
- WebGPU Architecture for Spatial Visualization — the architectural overview every polled device feeds into.
- Browser Support & Fallback Routing Strategies — where an exhausted retry loop or rejected device reroutes the session.
- Implementing WebGL2 Fallbacks When WebGPU Fails — the concrete fallback the
nullreturn triggers. - How to Configure WebGPU Adapter Limits for Large GeoJSON — tuning the limits this loop clamps, against a real vector payload.