Figma Rendering: Powered by WebGPU¶
Summary¶
Figma's canvas renderer — a C++ codebase compiled to
WebAssembly for the browser and to native
x64/arm64 for server-side rendering — shipped a year-long migration from
WebGL to WebGPU while keeping
WebGL as a peer backend. The payoff: compute shaders, cleaner
error handling, and escape from WebGL's bug-prone global state.
The work decomposed into five substrate-level projects: (1)
reshape the internal graphics-API abstraction so every draw call's
state is explicit (patterns/graphics-api-interface-layer);
(2) build a shader
translator that emits both GLSL and
WGSL from Figma's older WebGL-1 GLSL source (via
naga + a custom preprocessor); (3) batch
uniform uploads into a single buffer per submit() to avoid the
WebGPU-native per-uniform allocation cost
(patterns/uniform-buffer-batching); (4) a production rollout
architecture with a telemetry-driven device blocklist
(patterns/device-blocklist-from-telemetry) plus a runtime
backend-swap that can fall back from WebGPU to WebGL mid-session
(patterns/runtime-backend-swap-on-failure); (5) sharing
the same WebGPU C++ implementation between browser Wasm (via
Emscripten WebGPU bindings) and native builds
(via Dawn, Chromium's WebGPU implementation) so
client and server-side renderer share one graphics-API surface.
Outcome on production devices: *"a performance improvement when
using WebGPU on some classes of devices, and more neutral results
on others, but no regressions."
Key takeaways¶
-
Abstraction-layer redesign preceded the migration. Figma's pre-existing graphics interface mirrored WebGL's "bind-then-draw" global-state API. The first project was to make the graphics interface WebGPU-shaped — every per-draw resource (vertex buffer, framebuffer, texture, material) explicit as a
draw()argument rather than a separatebindX()call. This alone *"fixed a handful of bugs in our WebGL renderer before we even touched WebGPU." The implicit-state-bug class — forgetting to update one binding before a draw — disappears when state is a function argument. (Source: sources/2026-04-21-figma-rendering-powered-by-webgpu) -
Two shader languages, one source of truth. Figma's shaders are in WebGL-1 GLSL (older dialect); WebGPU requires WGSL. Maintaining both by hand was rejected ("not feasible"). The solution: a custom shader processor that parses the existing GLSL, emits a newer GLSL dialect for the WebGL backend, runs naga to translate the newer GLSL to WGSL, and extracts input types and data layouts for the Figma app. The processor also adds
#include-style file modularity. This is a canonical patterns/shader-source-translator-pipeline: one source feeds every GPU backend. -
Uniform-upload strategy is the performance landmine. In WebGL, individual
uniform1f-style calls are cheap. In WebGPU, "all uniforms must be supplied using a uniform buffer" — allocating GPU memory and uploading per draw would regress performance badly. Figma batches:encodeDraw(uniformStructData, ...)per draw, then one buffer upload and one draw-submission phase atsubmit()time with per-draw offsets into the shared buffer. The graphics interface was updated to support this encode/submit split on both backends (patterns/uniform-buffer-batching). -
Share WebGPU code between Wasm and native via Dawn. The same C++ WebGPU calls run (a) in the browser via Emscripten's built-in WebGPU JS bindings (being migrated to Dawn's
emdawnwebgpu), and (b) in native x64/arm64 builds via Dawn (the Chromium WebGPU implementation) translating to lower-level graphics APIs. "A benefit of this setup is that both our Wasm and native app use Dawn for translating WebGPU into lower-level graphics APIs." Cross- target code reuse with one graphics-API surface. -
WebGPU readback is async-only — you must design around it. WebGL supports synchronous pixel readback; WebGPU does not. Figma's WebGL init runs a compatibility probe (render pixels → read back → verify) to detect buggy GPUs/drivers and apply workarounds. Rewriting that probe for WebGPU's async readback would "increase load times by hundreds of milliseconds, which wasn't acceptable" — so it moved to a non-load-blocking post-session probe, with failing devices added to a blocklist before production rollout (patterns/device-blocklist-from-telemetry).
-
Mid-session backend swap — fallback as first-class. Even with the blocklist, Figma saw mid-session WebGPU failures on Windows (
requestDevice/requestAdapterthrowing after a device-lost event). The initial "run compatibility tests at file load" plan wasn't sufficient. Figma built a dynamic fallback: a session can start on WebGPU and switch to WebGL mid-session on test-probe failure or any other WebGPU failure. The mechanism extends the existing context-loss handler to swap backends instead of re-creating the same one (patterns/runtime-backend-swap-on-failure). -
Rollout used per-device fallback-rate as the blocklist signal. Once dynamic fallback was in place, rollout resumed but with a new gate: devices whose average fallback rate was high were blocklisted from WebGPU, because "falling back from WebGPU to WebGL can cause a hitch, which we'd like to avoid." This closes the loop from Key takeaway 5 — probe data and real-session-failure data both feed the same per-device-class blocklist.
-
Net performance outcome: some classes better, others neutral, no regressions. Figma split rollout results by GPU type, OS, and browser. "We saw a performance improvement when using WebGPU on some classes of devices, and more neutral results on others, but no regressions." Tuning work targeted the worst regressions first — caching and reusing
bindGroups, batching draw calls into fewerrenderPasses. -
Future work hinges on WebGPU-only primitives. The stated medium-term wins (not shipped yet in the post): compute shaders (for blur), MSAA (multi-sample anti-aliasing), and
RenderBundles(to reduce per-draw CPU overhead). None of these exist in WebGL; they're the actual rationale for the migration.
Architecture¶
The graphics-API abstraction layer¶
Before WebGPU, the interface was essentially a thin re-export of WebGL semantics:
context->bindVertexBuffer(vertexBuffer, ...);
context->bindTextureUniform(texture, ...);
context->bindMaterial(material, ...);
context->bindFramebuffer(framebuffer, ...);
context->draw();
State persisted after draw(). Forgetting to rebind one input
before the next draw() produced a whole class of bugs where the
draw silently used stale state.
After the refactor — all state is an argument to draw():
The WebGL implementation of draw() updates bindings lazily as
needed; the WebGPU implementation passes them directly to WebGPU's
explicit state API. This is the
patterns/graphics-api-interface-layer pattern — one interface,
two backends with different implementation strategies.
Shader translation pipeline¶
WebGL-1 GLSL (Figma's existing shaders)
│
▼
Custom shader processor ─────► Newer GLSL ─────► WebGL backend
- parses old GLSL │
- normalizes ▼
- extracts input types naga (open-source)
- adds #include handling │
▼
WGSL ─────► WebGPU backend
The processor also emits the input type + data layout metadata the Figma runtime needs to bind uniforms to shader inputs.
Uniform batching shape¶
The encode/submit boundary separates data authorship from GPU upload:
encodeDraw(uniformStructData_1, material_1, ...)
encodeDraw(uniformStructData_2, material_2, ...)
... many more encodes ...
submit()
submit() on the WebGPU backend: allocate one buffer large
enough for all encoded uniforms, write all of them in, push to the
GPU, issue draws that reference the buffer with per-draw byte
offsets.
submit() on the WebGL backend: just issue the existing
per-uniform WebGL calls in order.
One encode-time API, two very different implementations. The "same code, different performance characteristics across backends" shape is what makes the graphics-API abstraction layer load-bearing for the whole migration.
Shared C++ code between Wasm and native¶
Figma's renderer is C++ compiled two ways:
- Wasm (browser client): via Emscripten
with Emscripten's WebGPU bindings (migrating to
emdawnwebgpu); the C++ WebGPU calls ultimately hit the browser's WebGPU API. - Native x64/arm64 (server-side rendering, testing, debugging): via Dawn, which translates WebGPU to the native platform's low-level graphics API (Metal / Vulkan / D3D).
One C++ codebase, one graphics-API surface, minimal per-platform branching. The server-side renderer — referenced in Figma's prior AI search post as the thumbnail-generation path — shares this substrate.
Dynamic fallback state machine¶
session starts
│
▼
try WebGPU ─────► all WebGPU draws happen via WebGPU backend
│ │
│ ▼
│ device-lost event OR test-probe failure
│ │
│ ▼
│ swap backends: WebGPU → WebGL
│ │
│ ▼
│ continue session on WebGL (hitch but no crash)
│
└──► (if device was on blocklist) start directly on WebGL
Existing WebGL context-loss / WebGPU device-loss handlers already re-created the same context; the dynamic-fallback system overloads them to swap backends instead.
Operational numbers¶
- Rollout duration: ≈ 1 year of engineering work between the initial graphics-API interface redesign and full rollout.
- Readback-probe delta: synchronous-WebGL probe is in-flight on startup; equivalent WebGPU async-readback probe would have added "hundreds of milliseconds" to load time — infeasible, forced a shift to post-session probing.
- Device/OS/browser classes tested: Windows, Mac, ChromeOS with significant per-class performance variance.
- Net outcome: "performance improvement when using WebGPU on some classes of devices, and more neutral results on others, but no regressions"; continuing rollout gated on per-device fallback-rate blocklist.
Caveats / scope¶
- Post does not disclose which classes of devices see improvement vs. neutral; no per-GPU / per-browser numbers.
- No quantitative numbers on the uniform-batching win — the post calls out that naive per-uniform upload "would regress performance", but doesn't disclose the measured delta between naive and batched WebGPU paths.
- Future-work items (compute shaders for blur, MSAA, RenderBundles) are not shipped yet per the article's framing.
- The specific shader preprocessor implementation is not open-sourced — only the overall shape (parse → rewrite → naga) is disclosed.
- No discussion of the number of shaders migrated, the preprocessor's complexity in lines-of-code, or which GLSL→WGSL semantic translations required the most work.
- Mid-session fallback hitch cost not quantified in milliseconds.
- The blocklist policy (thresholds, aggregation windows, rollback on new driver releases) is not disclosed.
- Relationship to Figma's RenderServer (server-side rendering for thumbnails/SVG export) is stated ("used in server-side rendering, as well as testing and debugging") but not elaborated.
Relationship to existing wiki¶
- Sibling to the game-engine-framing post sources/2026-04-21-figma-how-figma-draws-inspiration-from-the-gaming-world: that post introduces the three-language Figma stack (patterns/game-engine-stack-for-web-canvas); this post is the deep-dive on the canvas/renderer leg of that stack. The WebGPU migration is exactly the kind of game-engine-shaped substrate work (low-level graphics API, shader translation, explicit GPU state) that justifies the game-engine framing.
- Same C++ / WASM stack as sources/2026-04-21-figma-supporting-faster-file-load-times-memory-optimizations-rust (Rust multiplayer server memory work) — both sit under the same three-language architecture, but on different legs: that post is the Rust multiplayer server; this one is the C++/WASM canvas.
- Pattern echo with patterns/measurement-driven-micro-optimization: Figma ran internal perf tests first, found the worst regressions, then tuned (bindGroup reuse, renderPass batching). Same measure-then-fix discipline as the Rust BTreeMap→Vec work.
- Pattern echo with patterns/phased-cdn-rollout-passthrough-managed-auto: the blocklist-then-expand rollout shape — gather signal, carve out problem classes, continue rollout — is the same shape used in Cloudflare's Shared Dictionaries rollout, just at a different substrate (GPU drivers vs. CDN edges).
- Pattern echo with patterns/automatic-provider-failover: the dynamic WebGPU→WebGL fallback is a failure-handling pattern at the graphics-API layer; the fallback-chain shape (try preferred → on failure swap to peer backend) mirrors the model-provider-failover pattern in AI gateways, though on a different substrate and without the circuit-breaker formalisation.
Raw file¶
raw/figma/2026-04-21-figma-rendering-powered-by-webgpu-fbb078bb.md
Source¶
- Original: https://www.figma.com/blog/figma-rendering-powered-by-webgpu/
- Raw markdown:
raw/figma/2026-04-21-figma-rendering-powered-by-webgpu-fbb078bb.md
Related¶
- companies/figma — this source surfaces on the Figma company page under Recent articles and in the Key systems / Key patterns / concepts rollups.
- systems/webgpu — the target API of the migration; this source is the canonical wiki anchor for how a mature C++/WASM canvas adopts WebGPU.
- systems/webgl — the source API; kept as a peer backend.
- systems/dawn — Chromium's WebGPU implementation; used on both Wasm (via emdawnwebgpu) and native builds.
- systems/emscripten — Figma's C++ → Wasm toolchain.
- systems/naga — open-source GLSL → WGSL shader translator.
- systems/wgsl / systems/glsl — the two shader languages.
- concepts/compute-shader — the core new capability WebGPU unlocks that motivates the migration.
- concepts/explicit-graphics-state — the API-design shift from WebGL's global state.
- concepts/uniform-buffer — the WebGPU uniform-upload model that forced the batching redesign.
- concepts/synchronous-vs-asynchronous-readback — the load-time-probe blocker that forced post-session probing.
- concepts/dynamic-backend-fallback — the first-class fallback pattern for a production session.
- concepts/graphics-api-abstraction-layer — the interface that made the migration tractable.
- patterns/graphics-api-interface-layer — the pattern instance.
- patterns/uniform-buffer-batching — the encode/submit batching pattern.
- patterns/shader-source-translator-pipeline — the one-source-many-targets shader toolchain.
- patterns/device-blocklist-from-telemetry — the rollout gating pattern.
- patterns/runtime-backend-swap-on-failure — the mid-session fallback pattern.
- patterns/game-engine-stack-for-web-canvas — Figma's three-language stack; this post is the C++/WASM canvas leg.
- Sibling: sources/2026-04-21-figma-how-figma-draws-inspiration-from-the-gaming-world — the architectural umbrella for the 2026-04-21 Figma batch.
- Sibling: sources/2026-04-21-figma-supporting-faster-file-load-times-memory-optimizations-rust — Rust multiplayer-server memory work on the same three-language stack, different leg.
- systems/figma-multiplayer-querygraph — separate subsystem on Figma's Rust server; not directly in this post but part of the same architectural umbrella.