Skip to content

FIGMA 2026-04-21 Tier 3-equivalent

Read original ↗

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

  1. 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 separate bindX() 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)

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

  3. 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 at submit() 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).

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

  5. 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).

  6. Mid-session backend swap — fallback as first-class. Even with the blocklist, Figma saw mid-session WebGPU failures on Windows (requestDevice / requestAdapter throwing 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).

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

  8. 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 fewer renderPasses.

  9. 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():

context->draw(vertexBuffer, framebuffer, {texture}, material, ...);

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

Last updated · 200 distilled / 1,178 read