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. |
Prerequisites
Before applying the decision procedure to a real pipeline, you should have:
- 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.
- 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.
- A negotiated
GPUDeviceon 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. - 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.
- A profiling method for each API — GPU timestamp queries on WebGPU and
EXT_disjoint_timer_query_webgl2on WebGL 2.0 — because a decision made on wall-clockperformance.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.
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.
// 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.
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);
}
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.
@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.
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.
GPUValidationErrorfrom a missing usage flag. Binding a buffer created withoutGPUBufferUsage.STORAGEto aread_writebinding fails at bind time. Detection: synchronous validation error naming the binding. Fix: addSTORAGE(andCOPY_SRC/COPY_DSTas 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 tonullunder 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 navigatorimplies a usable device. - Device lost mid-session. A driver reset or TDR invalidates every WebGPU buffer and pipeline at once. Detection:
device.lostresolves 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: usetimestamp-queryon WebGPU andEXT_disjoint_timer_query_webgl2on 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
- Benchmarking WebGPU and WebGL 2.0 Point Rendering — a runnable harness that renders N million points on both paths and times them fairly with GPU timestamps rather than wall-clock guesses.
Related
- Performance Tuning & Profiling for WebGPU Spatial Pipelines — the profiling and budgeting practices that turn this API choice into measured frame time.
- Compute Shader vs CPU Spatial Indexing — the same decide-then-measure framing applied to spatial index queries.
- Browser Support & Fallback Routing Strategies — how to detect WebGPU and route unsupported clients before committing to a backend.
- Implementing WebGL 2.0 Fallbacks When WebGPU Fails — the concrete fallback path the dual-path outcome depends on.
- Spatial Aggregation in GPU Memory — the compute-only workload that makes WebGPU non-optional for heatmaps and choropleths.
Up: Performance Tuning & Profiling for WebGPU Spatial Pipelines