WebGPU Browser Support & Fallback Routing Strategies
A spatial application that renders continental vector tiles, LiDAR point clouds, or animated choropleths cannot assume the browser in front of it will hand back a conformant GPUDevice. WebGPU now ships in every major engine, but the real deployment surface is wider than the spec badge suggests: enterprise group policy disables hardware acceleration, headless Linux runners expose no adapter, older integrated GPUs report maxStorageBufferBindingSize well below what a 256 MB tile buffer needs, and software adapters silently fall back to a path too slow to hold a frame budget. The concrete scenario this page addresses is the moment navigator.gpu.requestAdapter() either returns null or returns an adapter whose limits cannot carry the dataset you intend to load — and how to route that session into a degraded but correct render path instead of presenting a blank canvas. This builds directly on the WebGPU Architecture for Spatial Visualization overview, where capability negotiation is the first stage of the pipeline.
Prerequisites
Before implementing the routing layer described here, you should have the following in place:
- WebGPU device-initialization fundamentals — you understand the
requestAdapter()→requestDevice()handshake and how to request non-default limits, covered in Initializing WebGPU Devices for GIS Workloads. - A working WebGL2 render path — fallback routing is only useful if you have a Tier 2 renderer to route to. You need vertex/fragment GLSL ES 3.0 shaders, transform-feedback or instanced draw paths, and a context-loss handler already implemented.
- Target browser matrix — Chrome/Edge 113+, Firefox 141+ (WebGPU on by default on Windows), and Safari 26+ ship WebGPU; below those versions, or with
chrome://flagsacceleration disabled, you are on WebGL2. Treat anything older than WebGL2 (noWebGL2RenderingContext) as the Canvas 2D tier. - Data-format assumptions — your spatial payloads arrive as typed arrays (coordinate pairs as
Float32Array, indices asUint32Array) packed to a known stride. The translation layer below assumes you control that packing, ideally from a GeoParquet/Arrow decode step on the backend. - A frame budget target — interactive panning needs each frame inside 16.6 ms (60 fps); pick the degradation thresholds in the telemetry section relative to that number.
Capability Probing & Adapter Validation
Before instantiating any device, probe adapter capabilities with precision rather than trusting feature presence alone. navigator.gpu.requestAdapter() returns null in unsupported environments, but a successful acquisition still requires validating limits, features, and queue behavior against the specific spatial workload. GIS pipelines commonly need bgra8unorm-storage for storage-texture compositing, timestamp-query for frame profiling, float32-filterable for high-precision elevation sampling, and a maxStorageBufferBindingSize large enough to bind a tile’s coordinate array in one pass. When probing fails — or when limits fall below threshold, for example maxTextureDimension2D < 8192 or a maxBufferSize smaller than the largest tile you stream — the routing layer must intercept initialization and select a degraded path. This gating decides how the compute and render pipelines are partitioned across supported and unsupported runtimes, because a tier without compute support cannot run on-GPU spatial joins at all.
The probe runs asynchronously during application bootstrap and resolves to a tier decision plus the limits payload the renderer needs downstream:
type RenderTier = "webgpu" | "webgl2" | "canvas2d";
interface ProbeResult {
tier: RenderTier;
reason: string;
limits?: GPUSupportedLimits;
features?: GPUSupportedFeatures;
}
// GIS thresholds: a single continental tile buffer plus its spatial index
// must fit in one storage binding; textures must hold a 16k atlas.
const MIN_STORAGE_BINDING = 268_435_456; // 256 MiB
const MIN_TEXTURE_DIM = 16_384;
async function probeSpatialCapabilities(): Promise<ProbeResult> {
if (!navigator.gpu) {
return { tier: probeWebGL2(), reason: "navigator.gpu missing" };
}
// A hung driver must not stall startup: race the request against a timeout.
const adapter = await Promise.race([
navigator.gpu.requestAdapter({ powerPreference: "high-performance" }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1500)),
]);
if (!adapter) {
return { tier: probeWebGL2(), reason: "adapter null or timed out" };
}
const { limits, features } = adapter;
const meetsGISThreshold =
limits.maxStorageBufferBindingSize >= MIN_STORAGE_BINDING &&
limits.maxTextureDimension2D >= MIN_TEXTURE_DIM &&
features.has("bgra8unorm-storage");
return meetsGISThreshold
? { tier: "webgpu", reason: "conformant adapter", limits, features }
: { tier: probeWebGL2(), reason: "limits below GIS threshold", limits, features };
}
function probeWebGL2(): RenderTier {
if (typeof WebGL2RenderingContext === "undefined") return "canvas2d";
const probe = document.createElement("canvas");
return probe.getContext("webgl2") ? "webgl2" : "canvas2d";
}
Note the Promise.race guard: GPURequestAdapterOptions does not accept an AbortSignal, so a wrapped timeout is the only way to keep a stalled adapter request from blocking first paint. The same retry-and-timeout discipline applies during device acquisition, where transient driver load can reject requestDevice() — see Setting Up WebGPU Device Polling for GIS Apps for the retry-orchestration pattern.
API & Threshold Reference
The routing decision is driven by a small set of adapter limits and features. The table below lists the gating values that matter for spatial workloads, the spec-guaranteed default, and the action the router takes when the reported value falls short.
| Limit / feature | Spec default (guaranteed) | GIS threshold used here | Action when below threshold |
|---|---|---|---|
maxBufferSize |
268,435,456 (256 MiB) | ≥ largest tile buffer | Split tiles, or drop to WebGL2 if splitting breaks indexing |
maxStorageBufferBindingSize |
134,217,728 (128 MiB) | 268,435,456 (256 MiB) | Drop to WebGL2 (no large compute-bound spatial join) |
maxTextureDimension2D |
8,192 | 16,384 | Reduce atlas size or drop to WebGL2 |
maxComputeWorkgroupsPerDimension |
65,535 | per-feature dispatch count | Tile the dispatch, or drop compute stage |
bgra8unorm-storage (feature) |
optional | required for storage compositing | Use render-pass compositing instead |
timestamp-query (feature) |
optional | desired for profiling | Disable GPU profiling; keep rAF telemetry |
float32-filterable (feature) |
optional | desired for elevation | Sample nearest, or quantize to f16 |
WebGL2RenderingContext |
n/a (browser global) | must exist for Tier 2 | Drop to Canvas 2D tier |
Keep this table as the single source of truth for thresholds; the probe constants above and the router below should read from the same values so a policy change is a one-line edit.
Routing Architecture & State Machine Design
A production routing strategy decouples pipeline logic from the underlying graphics API. The router resolves the tier once, then exposes a uniform renderer interface so downstream visualization modules — layer managers, tile schedulers, interaction handlers — never branch on which API is live. The interface normalizes the three operations that differ across runtimes: buffer upload, shader-module compilation, and the draw/dispatch call.
interface SpatialRenderer {
readonly tier: RenderTier;
uploadCoordinates(data: Float32Array, stride: number): GpuHandle;
compilePass(name: string): Promise<void>;
drawFrame(view: ViewState): void;
destroy(): void;
}
async function createRenderer(canvas: HTMLCanvasElement): Promise<SpatialRenderer> {
const probe = await probeSpatialCapabilities();
switch (probe.tier) {
case "webgpu":
return new WebGPURenderer(canvas, probe.limits!, probe.features!);
case "webgl2":
return new WebGL2Renderer(canvas); // transform feedback + instancing
case "canvas2d":
return new Canvas2DRenderer(canvas); // CPU raster, last resort
}
}
When WebGPU initializes, the renderer routes spatial datasets through compute shaders for parallelized spatial joins and tile aggregation. If the probe reported sufficient adapter limits but the device later reports an inadequate compute queue, the WebGPU renderer can itself fall back internally to render-pass-driven rasterization with instanced geometry — a same-tier degradation that avoids tearing down the whole context. The detailed mechanics of the Tier 2 path — context negotiation, GLSL shader supply, and precision-preserving uploads — live in Implementing WebGL2 Fallbacks When WebGPU Fails.
Fallback Tier Implementation & Data Translation
When the router selects a lower tier, spatial data structures must be translated without losing coordinate precision. WebGPU’s native buffer alignment rules differ significantly from WebGL2’s ARRAY_BUFFER constraints and from a CPU typed-array layout. A vec4<f32> coordinate that the WebGPU path expects 16-byte aligned packs differently into a WebGL2 attribute buffer, and a mat4x4<f32> transform that satisfies WGSL’s per-column alignment must be re-strided for a GLSL mat4 uniform. The translation layer implements a dynamic stride calculator that remaps vec4 coordinates and mat4 matrices to the target runtime’s memory model, so the same decoded GeoParquet payload feeds every tier.
// Re-stride a WebGPU-aligned coordinate buffer for a WebGL2 attribute buffer.
// WebGPU pads each vertex struct to a 16-byte boundary; WebGL2 wants tight packing.
function repackForWebGL2(src: Float32Array, gpuStrideBytes: number): Float32Array {
const gpuStride = gpuStrideBytes / 4; // floats per vertex on the GPU path
const glStride = 4; // x, y, z, attr — tightly packed
const count = src.length / gpuStride;
const out = new Float32Array(count * glStride);
for (let i = 0; i < count; i++) {
const s = i * gpuStride;
const d = i * glStride;
out[d] = src[s]; // longitude / projected x
out[d + 1] = src[s + 1]; // latitude / projected y
out[d + 2] = src[s + 2]; // elevation
out[d + 3] = src[s + 3]; // packed attribute (ignore trailing GPU padding)
}
return out;
}
Getting the stride wrong is the single most common source of visual tearing in large coordinate reference systems: an off-by-one float in the stride shifts every subsequent vertex, smearing geometry diagonally across the viewport. Because the alignment contract is shared between the GPU upload path and this re-strider, treat the memory alignment for spatial data buffers rules as the authority for the source layout and derive the fallback stride from it rather than hand-coding both ends.
Shader Translation & Build-Time Optimization
Runtime shader compilation introduces unacceptable latency during initial map load — a cold WGSL-to-GLSL transpile on the main thread can cost tens of milliseconds per module while the user stares at a blank tile grid. Pre-compile WGSL to GLSL ES 3.0 during the build phase using a translation tool such as Naga (the shader-translation library inside the wgpu repository) or a custom AST transformer, and ship both the WGSL and the validated GLSL variants as build artifacts. The WebGL2 tier then loads optimized, pre-validated modules with no parse overhead at runtime. For the CPU tier, the Canvas 2D renderer leans on OffscreenCanvas with CanvasRenderingContext2D for baseline vector rendering, reserving any available GPU context purely for heavy tile compositing.
This build-time split also keeps the two render paths semantically aligned: when the same source shader produces both targets, a projection or culling change is made once and propagates to every tier, rather than drifting between a hand-written GLSL fallback and the canonical WGSL.
Memory & Performance Implications
Each tier has a distinct cost profile, and the router’s job is partly to keep a session on the tier whose profile fits the device:
- VRAM footprint. The WebGPU tier holds the full coordinate buffer (≈256 MB for a dense continental tile set), its spatial index, and intermediate compute outputs in device memory simultaneously. On an adapter near the threshold, this is exactly the budget that overflows; the WebGL2 tier sidesteps the compute-output buffers but cannot bind the full coordinate array in one storage binding, which is why large on-GPU spatial joins are a Tier 1 exclusive — see configuring adapter limits for large GeoJSON.
- CPU↔GPU transfer cost. Every
queue.writeBuffer(WebGPU) orbufferData(WebGL2) is a PCIe copy. The re-strider above runs on the CPU and adds one full pass over the vertex array per tile on the fallback path; for streaming workloads, move it into the GeoParquet decode worker so it overlaps with network fetch rather than blocking the frame. - Workgroup sizing. On Tier 1, size compute dispatches so
@workgroup_sizetotals stay withinmaxComputeInvocationsPerWorkgroup(256 by default) and the per-feature dispatch count stays undermaxComputeWorkgroupsPerDimension. A spatial kernel that maps one invocation to one feature must tile its dispatch when feature counts exceed 65,535 — a constraint the WebGL2 tier never hits because it never dispatches compute. - Frame budget. The Canvas 2D tier cannot hold 60 fps for dense geometry; treat it as a correctness floor (the map draws and stays interactive at reduced detail), not a performance target.
Telemetry & Frame Budgeting
Deterministic routing requires continuous telemetry. Track adapter-negotiation success rates, fallback-activation triggers (which threshold failed, on which device class), and frame-time distributions per tier. Implement a watchdog over requestAnimationFrame deltas: if a tier consistently exceeds the 16.6 ms budget, the renderer downgrades complexity rather than the API — reduce tile resolution, disable compute-driven spatial indexing, or switch to static pre-baked meshes before considering a full tier change.
class FrameWatchdog {
private last = performance.now();
private overruns = 0;
constructor(
private readonly budgetMs = 16.6,
private readonly window = 30, // frames before acting
private readonly onDowngrade: () => void,
) {}
tick(): void {
const now = performance.now();
const delta = now - this.last;
this.last = now;
if (delta > this.budgetMs) this.overruns++;
else this.overruns = Math.max(0, this.overruns - 1);
if (this.overruns >= this.window) {
this.overruns = 0;
this.onDowngrade(); // lower LOD / disable compute indexing / static mesh
}
}
}
For authoritative API behavior and feature-detection semantics, consult the W3C WebGPU Specification and the MDN WebGL2RenderingContext reference.
Failure Modes & Diagnostics
Routing introduces its own failure surface beyond a plain “no GPU” outcome. The named cases below are the ones that actually break spatial deployments:
OperationError/ null fromrequestAdapter()under driver load. A busy or recovering driver can returnnulleven on capable hardware. Detection: the timeout in the probe fires while a later retry succeeds. Recovery: retry with backoff before committing to a lower tier, using the device-polling pattern; do not permanently demote a capable device on one transient miss.GPUValidationErroron bind-group creation after a same-tier downgrade. Switching from compute-bound to render-bound buffers mid-session can leave usage flags inconsistent (STORAGEvsVERTEX). Detection: push an error scope (device.pushErrorScope("validation")) around bind-group creation. Recovery: recreate buffers with the union of required usage flags, or rebuild the renderer at the chosen tier.GPUDevice.lost(device lost) during a long pan/zoom session. The OS may reset the GPU (TDR), invalidating every resource. Detection:await device.lostresolves with a reason. Recovery: tear down and re-run the router from the probe — a lost device may now fail the threshold and route to WebGL2, which is the correct behavior.- WebGL2 context loss on the fallback tier. The Tier 2 path is not immune;
webglcontextlostfires under the same memory pressure. Detection: the event listener. Recovery:preventDefault(), awaitwebglcontextrestored, and re-upload buffers — which is why the re-strider must be idempotent and re-runnable. - Silent precision loss with no error. A wrong fallback stride produces no exception at all — geometry simply tears. Detection belongs in CI, not runtime: snapshot a known tile on each tier and diff against a reference render. This class is the strongest argument for deriving the fallback stride from the alignment rules rather than maintaining two layouts by hand.
Frequently Asked Questions
Is a WebGL2 fallback still necessary now that WebGPU ships everywhere? Yes. Driver maturity, disabled hardware acceleration, restricted enterprise environments, and below-threshold limits all produce sessions where a conformant device is unavailable on a browser that nominally “supports” WebGPU. Capability-aware routing, not version sniffing, is what keeps those sessions rendering.
Should I feature-detect with navigator.gpu or by requesting an adapter? Always request the adapter (with a timeout). navigator.gpu existing only proves the API surface is present; it says nothing about whether an adapter resolves or whether its limits clear your GIS thresholds.
Can a session move back up a tier after degrading? Re-probe on a meaningful event (device lost, page revisit), not continuously. Repeated up-tier attempts during a session add churn for little gain; the safe default is to commit to the resolved tier until a lost-context event forces a fresh decision.
Does the Canvas 2D tier need full feature parity? No — it is a correctness floor. Render the base map and keep interaction working at reduced detail; do not attempt compute-driven indexing or large compositing there.
In this section
- Implementing WebGL2 Fallbacks When WebGPU Fails — context acquisition, GLSL shader supply, and precision-preserving buffer uploads for the Tier 2 render path.
Related
- WebGPU Compute vs Render Pipeline Fundamentals — what the WebGPU tier gains over the fallback paths.
- Memory Alignment for Spatial Data Buffers — the source layout the cross-tier re-strider must honor.
- Initializing WebGPU Devices for GIS Workloads — the device handshake the probe sits in front of.
- Setting Up WebGPU Device Polling for GIS Apps — retry orchestration for transient adapter/device failures.