WebGPU vs WebGL: real numbers

"WebGPU is faster than WebGL" gets repeated a lot, usually without numbers. So I wrote a small benchmark that draws the same scene both ways, ran it, and wrote down what came out. Some of it matched the folklore. Some of it did not: on my machine, thousands of separate WebGPU draw calls cost more JavaScript time than WebGL2, and at 50,000 draws WebGPU was the one that dropped frames.

What this compares, and what it does not

The games on this site, including City Drive, are WebGPU only. There is no WebGL version of any of them, so comparing them would mean comparing against something imaginary. Instead, this is a micro-benchmark of one thing that both APIs do and that people argue about: issuing lots of small draw calls, each with its own data.

That is the classic "draw call overhead" question. It is not a test of shader speed, fill rate or memory bandwidth. The GPU work here is deliberately tiny, so any difference comes from the cost of telling the GPU what to do.

You can run the benchmark yourself. It runs right here on the site with no install, and it also exposes its results on window.benchmarkResults.

The benchmark page after a run, with a results table listing the four modes at 1,000, 10,000 and 50,000 objects, their CPU time, upload time and frames per second
The benchmark page after one run on the machine described below. This was a separate, fifth run made for the screenshot, so its numbers are close to the table below but not part of it.

The benchmark

Every mode draws N small coloured quads at random positions on a 512 × 512 canvas. Each quad has eight floats of its own: an offset, a size and a colour. Every frame, JavaScript moves every quad slightly, so the per-object data really does change each frame. There are four modes:

ModePer frameDraw calls
WebGL2, per objectOne uniform4fv and one drawArrays per quadN
WebGPU, per objectOne writeBuffer, then one setBindGroup with a dynamic offset and one draw per quadN
WebGL2, instancedOne bufferSubData and one drawArraysInstanced1
WebGPU, instancedOne writeBuffer to a storage buffer and one draw with N instances1

The per-object loops are the heart of it. In WebGL2, each quad's eight floats go straight into a uniform vec4 objectData[2] array:

function drawEachObject() {
  const { objects, objectCount } = scene;
  for (let index = 0; index < objectCount; index++) {
    gl.uniform4fv(objectDataLocation, objects, index * FLOATS_PER_OBJECT, FLOATS_PER_OBJECT);
    gl.drawArrays(gl.TRIANGLES, 0, VERTICES_PER_QUAD);
  }
}

WebGPU has no per-draw uniform call: a queue.writeBuffer takes effect before the whole submitted pass, not between two draws. The idiomatic equivalent is to upload all objects into one uniform buffer before the pass, then point the bind group at a different slice for each draw with a dynamic offset. Uniform buffer offsets must be multiples of 256 bytes (minUniformBufferOffsetAlignment on this adapter), so each 32-byte object gets a 256-byte slot.

function recordDraws(pass) {
  pass.setPipeline(pipeline);
  pass.setVertexBuffer(0, cornerBuffer);
  if (isInstanced) {
    pass.setBindGroup(0, bindGroup);
    pass.draw(VERTICES_PER_QUAD, scene.objectCount);
    return;
  }
  for (let index = 0; index < scene.objectCount; index++) {
    pass.setBindGroup(0, bindGroup, dynamicOffsets, index, 1);
    pass.draw(VERTICES_PER_QUAD);
  }
}

The loop uses the setBindGroup overload that takes a Uint32Array plus a start and length, so it does not allocate a new offsets array for each of the 50,000 calls. The WebGL2 loop is just as lean: uniform4fv with a source offset reads straight from the shared Float32Array.

The instanced modes are there as a control. They draw the identical picture with one call, so they show what the same workload costs when draw calls are taken out of the equation.

Machine and method

Everything below was measured on one machine, read from the browser itself where possible:

ItemValue
ComputerApple M1 Pro, 16 GB, macOS 27.0.1
BrowserChrome 157.0.8103.38, headless (--headless=new --enable-unsafe-webgpu)
adapter.infovendor "apple", architecture "metal-3" (device and description empty)
WebGL rendererANGLE (Apple, ANGLE Metal Renderer: Apple M1 Pro, Unspecified Version)
navigator.userAgentMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) … HeadlessChrome/157.0.0.0 Safari/537.36

Both APIs end up on Metal: WebGL2 through ANGLE, WebGPU through Dawn. The user agent string's "Intel Mac OS X 10_15_7" is Chrome's frozen value, not the real OS.

Each mode runs on a fresh canvas, one after another, in a requestAnimationFrame loop: 20 warm-up frames, then 120 measured frames. For each frame the page records:

I ran the full set (three object counts, four modes) four times, driving Chrome over the DevTools protocol and reading window.benchmarkResults. The page is not cross-origin isolated, so Chrome reported performance.now() in 0.1 ms steps. I quote the mean of each run's 120 frames, which averages that rounding out; the benchmark page also shows medians and 95th percentiles.

Results

Ranges are the lowest and highest of the four runs. Frame rate was capped at 60 by the display loop, so "60" means "kept up", not "60 is the limit of the API".

ObjectsModeJavaScript ms per frameof which uploadFPS
1,000WebGL2, per object0.16–0.45none60
1,000WebGPU, per object0.40–0.910.17–0.3860
1,000WebGL2, instanced0.03–0.050.01–0.0260
1,000WebGPU, instanced0.14–0.360.04–0.0960
10,000WebGL2, per object0.87–1.31none60
10,000WebGPU, per object1.69–1.850.87–0.9660
10,000WebGL2, instanced0.07–0.090.04–0.0560
10,000WebGPU, instanced0.35–0.520.23–0.3760
50,000WebGL2, per object4.89–5.01none60
50,000WebGPU, per object3.96–4.110.3343.0–43.3
50,000WebGL2, instanced0.08–0.120.06–0.0960
50,000WebGPU, instanced0.83–1.380.78–1.3060
Bar chart of mean JavaScript time per frame. 1,000 objects: WebGL2 per object 0.27 ms, WebGPU per object 0.62, WebGL2 instanced 0.04, WebGPU instanced 0.25. 10,000: 1.18, 1.79, 0.08, 0.44. 50,000: 4.95, 4.05 at 43 fps, 0.11, 1.08. 0 1 2 3 4 5 ms 0.27 0.62 0.04 0.25 1.18 1.79 0.08 0.44 4.95 4.05 43 fps 0.11 1.08 1,000 objects 10,000 objects 50,000 objects WebGL2 per object WebGPU per object WebGL2 instanced WebGPU instanced
Mean JavaScript time per frame, averaged over the four runs. Every mode held 60 fps except WebGPU per object at 50,000 draws.

Three things stand out:

  1. With one draw per object, WebGL2 spent less JavaScript time than WebGPU at 1,000 and 10,000 objects.
  2. At 50,000, WebGPU's JavaScript time was about 18% lower, yet it was the only mode that could not hold 60 fps.
  3. Instancing beat every per-object mode by a wide margin in both APIs, and WebGL2 instancing had the lowest JavaScript time of all.

None of that says WebGPU is slow. It says the cost of a draw call does not live where you would guess, and that performance.now() around your render function measures only the part you can see.

Why WebGPU's JavaScript time is not lower

The upload column explains a lot of it. Because of the 256-byte alignment, the WebGPU per-object mode writes 256 bytes per object: 2.56 MB a frame at 10,000 objects, for 320 KB of actual data. That writeBuffer alone took 0.87–0.96 ms, about half the frame's JavaScript time. WebGL2 has no separate upload at all; its 32 bytes per object travel inside each uniform4fv call.

Take the upload away and the per-draw cost is close. At 50,000 objects, WebGPU's remaining 3.7 ms works out at roughly 75 ns per setBindGroup plus draw, against roughly 100 ns per uniform4fv plus drawArrays in WebGL2. Those are averages of a whole frame divided by the number of objects, not a measurement of a single call.

The upload numbers are not tidy. Writing 12.8 MB at 50,000 objects took 0.33 ms in every run, less than the 2.56 MB write at 10,000. I do not have an explanation I can back up, so I am reporting it as measured rather than guessing at Chrome's internals.

WebGPU also has a fixed cost per frame that does not show up in WebGL2's JavaScript time. Each frame calls getCurrentTexture(), creates a view, a command encoder and a render pass, then finishes and submits. You can see it in the instanced rows: at 1,000 objects, with only a tiny upload, WebGPU spent 0.14–0.36 ms against WebGL2's 0.03–0.05 ms. That is small in absolute terms, but it is there in every frame.

If the dynamic-offset upload bothers you, there are other designs. A storage buffer indexed by instance_index, with each draw passing its object number as firstInstance, avoids the padding. I benchmarked the common textbook pattern, not the cleverest one.

Why 50,000 WebGPU draws ran at 43 fps

At 50,000 objects, WebGPU spent about 4 ms of JavaScript per frame, a quarter of a 60 fps budget, and still ran at 43 fps. Something other than JavaScript was the bottleneck.

In Chrome, neither API talks to the GPU from the page's thread. WebGL calls are put into a command buffer and executed by ANGLE in the separate GPU process. WebGPU calls are serialised by the page and replayed by Dawn in the GPU process, which validates them and translates them to Metal. So I recorded three-second Chrome traces while each mode was running and added up how busy the GPU process's main thread was:

ModeObjectsGPU process main thread busyPage main thread busy
WebGL2, per object10,00020.8%10.3%
WebGPU, per object10,00032.9%15.4%
WebGL2, per object50,00054.5%36.2%
WebGPU, per object50,00099.6%29.6%
WebGL2, instanced50,0007.1%21.8%
WebGPU, instanced50,0004.7%21.3%

These come from one trace per row, so treat them as indicative. The picture is still clear: at 50,000 draws, the GPU process was busy for 99.6% of the time with WebGPU, and the frame rate was capped by how fast it could get through 100,000 commands a frame. WebGL2 needed about half of that thread for the same scene. In the instanced rows, much of the page's main-thread time goes on moving 50,000 quads in JavaScript, which happens outside the timed section.

To check that the 12.8 MB upload was not the real culprit, I ran a modified copy of the page that skipped writeBuffer in the WebGPU per-object mode. It drew nothing useful, since the buffer was never filled, but in a single run it gave 3.7 ms of JavaScript, 47 fps and a GPU process still 99.8% busy. The upload costs a few frames per second; the per-draw work in the GPU process is the wall.

This is the honest version of "WebGPU has lower draw-call overhead". WebGPU moves validation out of the draw call: pipelines and bind groups are checked when you create them, so each draw has less to check than a WebGL call that must look at the whole global state. But in a browser every command still crosses a process boundary and gets checked for safety on the other side. On this machine and Chrome build, that path was more expensive per draw for WebGPU than for WebGL2.

What instancing changes

Both instanced modes drew all 50,000 quads in a single call, held 60 fps easily, and left the GPU process mostly idle. WebGPU instancing at 50,000 objects cost about a fifth of the JavaScript time of WebGL2 per-object drawing at the same count, and most of that was the upload.

That is the real lesson of this benchmark, and it applies to both APIs: the fastest draw call is the one you do not make. If thousands of objects share a mesh and a pipeline, put their data in a buffer and draw them together. City Drive draws its trees this way, with one instanced draw per species and level of detail. The instancing tutorial shows how.

Differences a frame time cannot show

If draw-call speed is not a clear win, why use WebGPU at all? Because most of its advantages are about what you can do, not how fast you can make many small calls. The WebGPU introduction covers them properly; briefly:

Key takeaways

  • On an M1 Pro in Chrome 157, WebGL2 used less JavaScript time than WebGPU for 1,000 and 10,000 separate draws.
  • At 50,000 separate draws, WebGPU used less JavaScript time but ran at 43 fps, because Chrome's GPU process was saturated.
  • Timing your render function measures only the page's share. Check the frame rate, and a trace, before drawing conclusions.
  • WebGPU's 256-byte uniform offset alignment makes the dynamic-offset pattern upload eight times more data than it uses here.
  • Instancing beat per-object drawing in both APIs by far more than either API beat the other.
  • Choose WebGPU for compute, WGSL, explicit pipelines and better errors, not on the assumption that every draw call is cheaper.

How far to trust these numbers

Not too far. This is one machine, one browser build and one operating system, in headless mode, with the frame rate tied to a 60 Hz animation loop. A Windows PC with Direct3D underneath, a different GPU, a future Chrome build or Firefox and Safari could all change the picture, and Dawn and ANGLE both get faster over time.

The workload is also deliberately unrealistic. Real scenes change pipelines, textures and buffers between draws, which costs WebGL more state work and is where WebGPU's pre-validated pipelines and bind groups should help most. This benchmark changes almost nothing between draws, which is WebGL2's best case.

And it measures CPU-side cost only. I did not measure GPU execution time, and the GPU process numbers come from one trace per mode.

That is exactly why the benchmark is public. Run it on your machine, change the object counts in the box at the top, and you will get your own table in a minute or two. If your numbers disagree with mine, believe yours.

Official documentation