WebGPU vs WebGL 2.0 for Spatial Workloads

The choice is rarely “which API is faster” — it is “which API removes the bottleneck your specific spatial workload actually hits.” A basemap that draws a few thousand polygons at 60 fps is bottlenecked by nothing, and WebGL 2.0 renders it with a decade of driver maturity behind it. A pipeline that re-bins ten million GPS pings into a density grid on every pan is bottlenecked by the fact that WebGL 2.0 has no first-class compute stage at all, so the reduction either runs on the CPU or is smuggled through a fragment shader that writes to a framebuffer. WebGPU exposes storage buffers, compute pipelines, and explicit command submission that make that reduction a native operation. This reference lays out where each API wins for GIS rendering and compute, contrasts the equivalent code paths side by side, and gives you a decision procedure rather than a benchmark to argue over.

This is one area of Performance Tuning & Profiling for WebGPU Spatial Pipelines. It sits alongside the profiling work, because the honest way to pick an API is to measure both on your own data — the companion guide on benchmarking WebGPU and WebGL 2.0 point rendering builds a fair harness for exactly that. The moment you commit to WebGPU as the primary path you inherit a support obligation, so this page assumes you have read the browser support and fallback routing strategies and are prepared to ship the WebGL 2.0 path described in implementing WebGL 2.0 fallbacks when WebGPU fails.

When to use each

The deciding axis is almost never raw triangle throughput — both APIs saturate the same rasterizer hardware. It is whether the workload needs general-purpose compute over spatial data (clustering, aggregation, on-GPU indexing, stream compaction) and whether CPU-side command overhead is your frame budget’s dominant cost. WebGL 2.0’s stateful, one-call-per-state-change model burns main-thread time that WebGPU’s pre-baked pipeline objects and reusable bind groups eliminate. Map the workload to the recommended API with the matrix below, then confirm the branch with the decision tree.

Spatial workload Recommended API Why
Static or slowly-changing basemap, < 100k features WebGL 2.0 No compute need; mature drivers, smallest bundle, universal support.
Choropleth / heatmap re-binned every pan, millions of points WebGPU Atomic binning in a compute pass; WebGL 2.0 forces a CPU reduction or a fragment-shader hack.
On-GPU spatial index queries (grid, quadtree, R-tree) WebGPU Storage buffers with read-write access; WebGL 2.0 has no equivalent random-access writable buffer.
Instanced markers, 1–5 M points, per-frame attribute churn WebGPU Reusable bind groups cut per-frame CPU overhead; storage buffers feed instancing without re-upload.
Instanced markers, < 500k points, static attributes Either WebGL 2.0 instancedArrays is sufficient and ubiquitous; WebGPU wins only under heavy churn.
Vector-tile viewport culling on the GPU WebGPU Compute-driven indirect draw; WebGL 2.0 lacks drawIndirect and writable buffers.
Point-cloud LOD / stream compaction WebGPU Prefix-sum compaction in compute; transform feedback in WebGL 2.0 is serial and awkward.
Must ship today to Safari < 18 or older mobile with no WebGPU WebGL 2.0 (or dual-path) WebGPU is unavailable; a fallback path is mandatory.
Cross-cutting: any of the above but broad reach required Dual-path Primary WebGPU with a WebGL 2.0 fallback selected at device-acquisition time.
Decision tree for choosing WebGPU, WebGL 2.0, or a dual-path build for a spatial workload A top-down decision tree. It begins with a start node, a spatial rendering or compute workload. The first question asks whether the pipeline contains a compute pass or a storage-buffer readback such as clustering, aggregation, or GPU-side indexing; a Yes branch goes right to the outcome Use WebGPU. A No branch goes down to a second question, whether the product must ship today to browsers without WebGPU such as Safari before version 18 or older mobile; a Yes branch goes left to the outcome Dual-path, a WebGPU primary with a WebGL 2.0 fallback. A No branch goes down to a third question, whether the workload renders more than two million instanced features per frame or performs large per-frame data uploads; a Yes branch goes right to a second Use WebGPU outcome, and a No branch goes down to the final outcome, WebGL 2.0 is sufficient. Question nodes are gold, WebGPU outcomes violet, the dual-path outcome coral, and the WebGL 2.0 outcome teal. Yes No Yes No Yes No Spatial rendering / compute workload Compute pass or storage-buffer readback? clustering · aggregation · GPU-side indexing Must ship today to no-WebGPU browsers? Safari < 18 · older mobile > 2M instanced features per frame? or large per-frame data uploads Use WebGPU compute + storage buffers native Dual-path build WebGPU primary · WebGL 2.0 fallback Use WebGPU lower per-frame CPU overhead WebGL 2.0 is sufficient mature drivers · universal reach
Follow the tree top-down; the first Yes decides the API, and only a workload that answers No to every question stays on WebGL 2.0.

Prerequisites

Before applying the decision procedure to a real pipeline, you should have:

  1. A clear inventory of your workload’s stages — separate the pure-render stages (draw features) from the data-transform stages (bin, cluster, cull, compact). Only the second class benefits from WebGPU compute, and misclassifying a stage is the most common reason a migration under-delivers.
  2. A target browser and device matrix. WebGPU ships in Chrome and Edge 113+, Safari 18+, and Firefox 141+; anything older is WebGL 2.0 only. Decide the floor before you choose, and route unsupported clients through the browser support and fallback routing strategies.
  3. A negotiated GPUDevice on the WebGPU path with adapter limits inspected against your buffer sizes — device acquisition and limit negotiation are covered in initializing WebGPU devices for GIS workloads.
  4. Coordinates already projected into one linear space (Web Mercator metres or normalized tile space) so the two rendering paths consume byte-identical vertex buffers and a benchmark compares like with like.
  5. A profiling method for each API — GPU timestamp queries on WebGPU and EXT_disjoint_timer_query_webgl2 on WebGL 2.0 — because a decision made on wall-clock performance.now() alone will attribute driver-queue latency to the wrong stage.

Feature and capability reference

The table below is the capability gap that actually drives the decision. The rows that read “no equivalent” for WebGL 2.0 are the ones that make WebGPU non-optional for a workload, not merely faster.

Capability WebGPU WebGL 2.0 Impact on spatial workloads
General-purpose compute Compute pipelines + WGSL @compute No compute stage Binning, clustering, culling run natively on GPU vs a CPU loop or fragment-shader workaround.
Random-access writable buffers var<storage, read_write> Read-only in shaders (UBO/samplers) On-GPU spatial indices and scatter writes are possible only on WebGPU.
Atomics atomic<u32> / atomic<i32> None Lock-free density accumulation; WebGL 2.0 cannot express a parallel histogram.
Indirect draw / dispatch drawIndirect, dispatchWorkgroupsIndirect Not available GPU decides draw counts (viewport culling) without a CPU round-trip.
Instancing Vertex buffers + storage buffers drawArraysInstanced + vertexAttribDivisor Both instance; WebGPU feeds instance data from compute output with no re-upload.
Command model Pre-baked pipelines, reusable bind groups Stateful, one call per state change WebGPU cuts main-thread CPU overhead under heavy per-frame churn.
Timing timestamp-query feature EXT_disjoint_timer_query_webgl2 Both profile the GPU; APIs and pitfalls differ (see the benchmarking guide).
Browser support (2026) Chrome/Edge 113+, Safari 18+, Firefox 141+ Effectively universal WebGL 2.0 remains the safe floor; WebGPU needs a fallback.
Shading language WGSL GLSL ES 3.00 Separate shader sources; a dual-path build maintains both.

For authoritative wording on pipelines, storage buffers, and timestamp queries, consult the W3C WebGPU specification and the WGSL specification; the WebGL 2.0 surface is documented on MDN’s WebGL2RenderingContext reference.

Implementation walkthrough

The clearest way to see the gap is to write the same operation twice. Each step below contrasts the WebGPU path with the WebGL 2.0 path for one stage of a point-rendering-plus-aggregation pipeline, and names the spatial-data reason the difference matters.

Step 1 — Acquire the device or context

WebGPU negotiates an adapter and device asynchronously; WebGL 2.0 hands back a context synchronously. This asymmetry is why a dual-path app must decide its rendering backend before it builds any GPU resources — the WebGPU branch is a promise, the WebGL 2.0 branch is not.

typescript
type Backend =
  | { kind: "webgpu"; device: GPUDevice; ctx: GPUCanvasContext }
  | { kind: "webgl2"; gl: WebGL2RenderingContext };

async function acquireBackend(canvas: HTMLCanvasElement): Promise<Backend> {
  if ("gpu" in navigator) {
    const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
    if (adapter) {
      const device = await adapter.requestDevice();
      const ctx = canvas.getContext("webgpu") as GPUCanvasContext;
      ctx.configure({ device, format: navigator.gpu.getPreferredCanvasFormat(), alphaMode: "premultiplied" });
      return { kind: "webgpu", device, ctx };
    }
  }
  // Synchronous fallback: no adapter, no device — the older, universal path.
  const gl = canvas.getContext("webgl2", { antialias: false });
  if (!gl) throw new Error("neither WebGPU nor WebGL 2.0 is available");
  return { kind: "webgl2", gl };
}

The full routing logic — feature detection, adapter loss, and a graceful downgrade — lives in implementing WebGL 2.0 fallbacks when WebGPU fails; this branch is the minimal selector.

Step 2 — Upload a packed position buffer

Both APIs take a Float32Array of interleaved x, y pairs, but the usage flags differ in intent. WebGPU marks the buffer STORAGE when a compute pass will read it randomly; WebGL 2.0 can only bind it as a vertex attribute source, never as a shader-writable store.

typescript
// WebGPU: STORAGE so a compute pass can read positions at arbitrary indices.
function uploadWebGPU(device: GPUDevice, xy: Float32Array): GPUBuffer {
  const buf = device.createBuffer({
    size: xy.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(buf, 0, xy);
  return buf;
}

// WebGL 2.0: an ARRAY_BUFFER; usable as a vertex source only, not shader-writable.
function uploadWebGL2(gl: WebGL2RenderingContext, xy: Float32Array): WebGLBuffer {
  const buf = gl.createBuffer()!;
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, xy, gl.STATIC_DRAW);
  return buf;
}

The STORAGE flag is the whole difference: it is what lets the same 80 MiB of points feed both the renderer and an aggregation pass with no second copy, exactly as spatial aggregation in GPU memory relies on.

Step 3 — Draw instanced point markers

Both APIs instance a quad across N points. WebGL 2.0 wires per-instance data with vertexAttribDivisor; WebGPU declares a second vertex buffer with stepMode: "instance". The render output is identical — the cost difference shows up only under per-frame attribute churn.

typescript
function drawWebGL2(gl: WebGL2RenderingContext, quad: WebGLBuffer, inst: WebGLBuffer, n: number): void {
  gl.bindBuffer(gl.ARRAY_BUFFER, quad);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
  gl.enableVertexAttribArray(0);
  gl.bindBuffer(gl.ARRAY_BUFFER, inst);
  gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0);
  gl.enableVertexAttribArray(1);
  gl.vertexAttribDivisor(1, 1);          // advance attribute 1 once per instance
  gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, n);
}
typescript
function drawWebGPU(pass: GPURenderPassEncoder, pipeline: GPURenderPipeline,
                    bind: GPUBindGroup, quad: GPUBuffer, inst: GPUBuffer, n: number): void {
  pass.setPipeline(pipeline);            // instance step-mode is baked into the pipeline layout
  pass.setBindGroup(0, bind);            // reusable — no per-draw attribute re-binding
  pass.setVertexBuffer(0, quad);
  pass.setVertexBuffer(1, inst);         // stepMode: "instance" declared at pipeline creation
  pass.draw(4, n);                       // 4 verts, n instances
}

The WebGL 2.0 path re-issues attribute-pointer state on every draw; the WebGPU path bakes the vertex layout into the pipeline once and re-binds a group. At a few thousand markers this is invisible, but at millions of features redrawn each frame the accumulated vertexAttribPointer/enableVertexAttribArray calls become measurable main-thread time.

Step 4 — Aggregate points into a density grid

This is the stage with no WebGL 2.0 equivalent. WebGPU scatters points into a grid with atomics in a compute pass. WebGL 2.0 has no writable storage buffer and no atomics, so the only in-API option is additive blending into a framebuffer — one draw call per point batch, no per-cell integer counts, and no way to read intermediate sums back cheaply.

wgsl
@group(0) @binding(0) var<storage, read>       positions: array<vec2<f32>>;
@group(0) @binding(1) var<storage, read_write> grid:      array<atomic<u32>>;
@group(0) @binding(2) var<uniform>             extent:    vec4<f32>; // min_x,min_y,max_x,max_y

const GRID_W: u32 = 1024u;
const GRID_H: u32 = 1024u;

@compute @workgroup_size(256)
fn scatter(@builtin(global_invocation_id) gid: vec3<u32>) {
    let i = gid.x;
    if (i >= arrayLength(&positions)) { return; }        // guard the ragged tail
    let n = (positions[i] - extent.xy) / (extent.zw - extent.xy);
    if (n.x < 0.0 || n.x >= 1.0 || n.y < 0.0 || n.y >= 1.0) { return; } // drop out-of-extent
    let cell = u32(n.y * f32(GRID_H)) * GRID_W + u32(n.x * f32(GRID_W));
    atomicAdd(&grid[cell], 1u);                           // lock-free parallel binning
}

On WebGL 2.0 the same result is approximated by drawing every point as an additively blended fragment into an R32F-ish target, which caps precision, cannot express weighted integer counts, and forces a CPU readback if you need the numbers rather than the picture. The WebGPU compute path is why choropleth and heatmap re-binning is the canonical reason to move a spatial pipeline off WebGL 2.0.

Step 5 — Submit work and control CPU overhead

WebGL 2.0 executes commands eagerly against a global state machine; WebGPU records a command buffer and submits it once. For a pipeline that rebuilds many passes per frame, batching them into a single submit collapses driver-call overhead that WebGL 2.0 pays per state change.

typescript
function frameWebGPU(device: GPUDevice, record: (e: GPUCommandEncoder) => void): void {
  const encoder = device.createCommandEncoder();
  record(encoder);                       // scatter pass + smooth pass + render pass
  device.queue.submit([encoder.finish()]); // one submission for the whole frame
}

The buffer dependency between the compute passes and the render pass is honored by WebGPU’s submission model without an explicit barrier, so the aggregated grid is bound straight into the draw with no readback — the readback-free hand-off that spatial aggregation in GPU memory depends on.

Memory and performance implications

The two APIs share the same underlying hardware, so peak rasterization throughput is comparable; the differences are in CPU overhead, data movement, and what work can stay on the GPU at all. WebGL 2.0’s per-state-change call model spends main-thread time proportional to draw-call count, which is why a scene with many small layers stutters on the CPU long before the GPU is busy — WebGPU’s reusable bind groups and pre-baked pipelines flatten that cost. On the data-movement axis, any workload that would otherwise read a grid or index back to the CPU pays a PCIe round-trip on WebGL 2.0; WebGPU keeps the intermediate in VRAM and binds it directly, so the largest single cost simply disappears.

The compute gap changes the asymptotics, not just the constant. A CPU-side reduction of points is on one thread and competes with rendering for the main thread; the WebGPU scatter is across thousands of lanes with the dominant cost being atomic contention on hot cells, not arithmetic. That is a different scaling regime, and it is why the crossover favors WebGPU sharply as grows — quantified for point rendering in the benchmarking guide, and for index queries in the sibling area on compute shader vs CPU spatial indexing. The counterweight is fixed cost: WebGPU’s async device acquisition, pipeline compilation, and larger bundle are overhead a small basemap never recoups. Below roughly a hundred thousand static features with no compute stage, WebGL 2.0 is not just adequate — it is the faster choice end to end, because it skips the setup WebGPU cannot amortize.

Failure modes and diagnostics

  • Migrating a pure-render workload and seeing no gain. A basemap with no compute stage ported to WebGPU pays setup cost for nothing. Detection: frame time is flat or worse after migration, and a profiler shows the GPU idle. Fix: keep pure-render, low-feature-count scenes on WebGL 2.0; reserve WebGPU for pipelines with a compute or storage-buffer stage.
  • GPUValidationError from a missing usage flag. Binding a buffer created without GPUBufferUsage.STORAGE to a read_write binding fails at bind time. Detection: synchronous validation error naming the binding. Fix: add STORAGE (and COPY_SRC/COPY_DST as needed) at buffer creation; there is no WebGL 2.0 analog to forget because WebGL 2.0 has no writable storage.
  • No WebGPU adapter on a supported browser. requestAdapter() resolves to null under a blocklisted GPU, a headless context, or a disabled flag. Detection: the promise resolves null, not throws. Fix: treat null as “unsupported” and take the WebGL 2.0 branch from Step 1; never assume "gpu" in navigator implies a usable device.
  • Device lost mid-session. A driver reset or TDR invalidates every WebGPU buffer and pipeline at once. Detection: device.lost resolves with a reason. Fix: recreate the device and rebuild resources, or downgrade to WebGL 2.0 through the fallback routing strategies; a WebGL 2.0 context does not spontaneously drop the same way.
  • Unfair benchmark attributing latency to the wrong API. Timing with performance.now() around a submit measures queue latency, not GPU work, and flatters whichever API buffers less. Detection: results swing with unrelated background load. Fix: use timestamp-query on WebGPU and EXT_disjoint_timer_query_webgl2 on WebGL 2.0, as the benchmarking guide sets up.
  • Divergent visuals between the two paths. WGSL and GLSL ES 3.00 differ in precision defaults and clip-space conventions, so a dual-path build can render the same points slightly differently. Detection: markers shift by a sub-pixel or blend differently across backends. Fix: pin a shared projection in a uniform, match precision qualifiers, and snapshot-test both paths against the same input.

Continue in this section

Up: Performance Tuning & Profiling for WebGPU Spatial Pipelines