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:

  1. 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.
  2. 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.
  3. 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://flags acceleration disabled, you are on WebGL2. Treat anything older than WebGL2 (no WebGL2RenderingContext) as the Canvas 2D tier.
  4. Data-format assumptions — your spatial payloads arrive as typed arrays (coordinate pairs as Float32Array, indices as Uint32Array) packed to a known stride. The translation layer below assumes you control that packing, ideally from a GeoParquet/Arrow decode step on the backend.
  5. 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:

typescript
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

Capability-aware tier routing state machine A top-down decision chain. App bootstrap checks whether navigator.gpu is defined; if yes it checks whether requestAdapter resolves; if yes it checks whether adapter limits meet the GIS thresholds; if all three pass the session routes to Tier 1 WebGPU with compute and render pipelines. Any failing check — navigator.gpu missing, adapter null or timed out, or limits below threshold — diverts to a WebGL2 context check. If a WebGL2 context is creatable the session routes to Tier 2 WebGL2 with transform feedback and instanced draw; otherwise it routes to Tier 3 Canvas 2D CPU raster fallback. yes yes yes no no below threshold yes no App bootstrap navigator.gpu defined? requestAdapter() resolves? limits meet GIS thresholds? any failing check routes here WebGL2 context creatable? Tier 1 — WebGPU compute + render pipelines Tier 2 — WebGL2 transform feedback + instanced draw Tier 3 — Canvas 2D CPU raster fallback

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.

typescript
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.

typescript
// 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;
}
Cross-tier coordinate re-strider Two memory layouts of the same two vertices. The top row is the WebGPU buffer with an eight-float, sixteen-byte-aligned stride per vertex: four meaningful lanes (x, y, z, attr) followed by four padding floats. The bottom row is the WebGL2 attribute buffer with a tight four-float stride and no padding. Arrows map each kept lane of both vertices from the padded source to the tightly packed destination, showing the second vertex shifting left as the trailing GPU padding is dropped. Re-striding a vertex buffer from the WebGPU layout to the WebGL2 layout WebGPU buffer — 16-byte aligned, 8-float stride x y z a pad pad pad pad x y z a pad pad pad pad WebGL2 attribute buffer — tight 4-float stride x y z a x y z a Kept lanes (x, y, z, attr) repack tightly; trailing GPU padding is dropped — a wrong stride smears every later vertex diagonally.

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) or bufferData (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_size totals stay within maxComputeInvocationsPerWorkgroup (256 by default) and the per-feature dispatch count stays under maxComputeWorkgroupsPerDimension. 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.

typescript
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 from requestAdapter() under driver load. A busy or recovering driver can return null even 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.
  • GPUValidationError on bind-group creation after a same-tier downgrade. Switching from compute-bound to render-bound buffers mid-session can leave usage flags inconsistent (STORAGE vs VERTEX). 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.lost resolves 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; webglcontextlost fires under the same memory pressure. Detection: the event listener. Recovery: preventDefault(), await webglcontextrestored, 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

Up: WebGPU Architecture for Spatial Visualization