Memoizing GPU Pipelines Across React Renders

Creating a GPURenderPipeline compiles WGSL to native ISA, which takes tens of milliseconds. Creating one inside a React component means paying that cost on every render that does not hit a memo — and React’s memoization guarantees are weaker than most people assume: useMemo is explicitly a performance hint that React may discard, which makes it the wrong place to hold anything whose recreation is expensive or has side effects. This page is the cache that does hold pipelines correctly, the key it needs, and the interaction with device loss that decides when it must be thrown away. It is one stage of React state hydration for GPU contexts.

What a pipeline costs, and what a cache hit costs A bar chart in milliseconds. Creating a render pipeline synchronously costs about 38 milliseconds of main-thread time while the driver compiles the shader. Creating one asynchronously costs the same total but none of it blocking, so the frame budget is untouched. A cache hit costs about 0.002 milliseconds, which is a map lookup and a string comparison. PIPELINE ACQUISITION · ms frame budget createRenderPipeline 38 ms — blocks ...Async 38 ms, off the frame path Cache hit 0.002 ms 0 7 14 21 28 35 42 ms blocks the frame same work, no stall a map lookup Modelled proportions; compile time varies with shader complexity and driver.
Three orders of magnitude separate the first and last bars, which is why the cache matters more than any other optimisation in a React integration.

Runnable reference implementation

The cache lives outside React, keyed by everything the pipeline descriptor depends on. Components ask it for a pipeline; they never create one.

typescript
/** Everything that makes two pipelines different. Order matters — this is a key. */
interface PipelineKey {
  shader: string;            // module id, not the source
  vertexLayout: string;      // a stable serialisation of the buffer layout
  colorFormat: GPUTextureFormat;
  depthFormat: GPUTextureFormat | "none";
  sampleCount: number;
  blend: "opaque" | "premultiplied";
}

class PipelineCache {
  private readonly map = new Map<string, GPURenderPipeline>();

  constructor(private device: GPUDevice) {}

  get(key: PipelineKey, build: (d: GPUDevice) => GPURenderPipeline): GPURenderPipeline {
    const id = JSON.stringify(key);       // stable: the interface field order fixes it
    let pipeline = this.map.get(id);
    if (!pipeline) {
      pipeline = build(this.device);
      this.map.set(id, pipeline);
    }
    return pipeline;
  }

  /** After a device loss the old pipelines are invalid — drop them all. */
  rebind(device: GPUDevice): void {
    this.map.clear();
    this.device = device;
  }
}

The React side is then a useContext and a lookup, with no useMemo involved at all — because the cache, not React, owns the lifetime.

tsx
function PointLayer({ data }: { data: PointData }) {
  const { device, pipelines } = useGpu();          // context, created once
  const pipeline = pipelines.get(POINT_PIPELINE_KEY, buildPointPipeline);
  // ...record draw commands with `pipeline`
  return null;                                      // renders nothing itself
}

Parameter reference

Value Setting here Guidance
Cache location outside React A module-level or context-held object; never useMemo, which React may discard.
Key contents everything in the descriptor Formats, sample count, blend, layout. Omitting one produces a pipeline that is silently wrong.
Key stability a serialised object JSON.stringify of a fixed-shape interface is stable; stringifying an arbitrary object is not.
Async creation createRenderPipelineAsync Off the critical path at start-up; the sync form blocks.
Invalidation device loss only Pipelines do not depend on data, props or camera.
Where a GPU object can safely live in a React app A table of four storage locations with whether each is safe for a GPU object and why. A useMemo hook is not safe, because React documents the memo as a hint it may discard, which would silently recompile the pipeline. A useRef hook is safe for the lifetime of one component instance but not shared between components. A context value created once above the tree is safe and shared. A module-level singleton is safe and shared but survives hot reloads, which leaks during development. GPU OBJECT STORAGE · IS IT SAFE? Safe? Why useMemo no React may discard it useRef per component not shared Context, created once yes shared, one lifetime Module singleton mostly leaks across hot reloads The rule generalises: React holds references to GPU state, never the state itself.
The third row is the answer for anything shared. The first is the one people reach for and the one the React documentation explicitly warns against relying on.

Why useMemo is the wrong tool

useMemo is documented as a hint. React reserves the right to discard memoized values — to free memory, and in future to support features that reuse component state — and that is fine for a computed string and wrong for a GPU object, for two reasons.

The first is cost. A discarded pipeline is recompiled on the next render, which is tens of milliseconds on the main thread, at a moment the application did not choose. The user experiences it as an occasional inexplicable stall.

The second is ownership. A GPURenderPipeline does not need explicit destruction, but the same argument applies to buffers and textures, which do — and a useMemo that creates a buffer has no cleanup hook at all. useEffect has one, but it runs after render rather than during, so the render that wanted the buffer cannot have it. The escape from both problems is the same: keep GPU objects in a plain structure whose lifetime the application controls, and let React hold nothing but a reference to that structure.

Strict Mode makes the second problem visible in development by mounting, unmounting and remounting every component, and it is worth treating that as the test rather than as an annoyance — the same discipline described under avoiding GPU buffer leaks.

What belongs in the key

A pipeline cache with an incomplete key is worse than no cache, because it returns a pipeline that is valid and wrong. Five things belong in it.

The shader module identity, not its source text: two pipelines built from the same module differ only in state, and hashing source on every lookup is pointless work. The vertex buffer layout, serialised stably, because a layout mismatch is a validation error at draw time rather than at creation. The colour attachment format, which must match the swap chain. The depth format and comparison, or an explicit marker for none. And the blend state, because an opaque pipeline drawn where a premultiplied one was expected produces washed-out output with no error.

The sample count deserves a mention of its own: it is easy to forget because most applications have exactly one value for it, and it becomes a live bug the day someone adds multisampling to one layer. Including it costs nothing and removes the possibility.

What does not belong is anything about the data: the row count, the buffer identity, the camera. Those live in bind groups, which are cheap to create and correctly recreated when their resources change.

Warming the cache before the user needs it

A cache that is populated lazily still pays every compile — it just pays them at the worst possible moment, which is the first frame that shows each new layer. Warming it at start-up moves that cost somewhere nobody is watching.

The mechanism is createRenderPipelineAsync, which returns a promise and lets the driver compile off the critical path. Building every pipeline the application knows it will need, in parallel, during the same window that tiles are being fetched, means the first frame that needs one finds it already there. For an application with a dozen layer types that is a dozen promises resolved during a second of network wait that was going to happen anyway.

Knowing which pipelines to warm is the only real work. A style-driven map, where layer types come from a configuration document, can enumerate them from that document before any data arrives — which is one of the underrated advantages of declarative styling. An imperative application usually has a small fixed set and can simply list them.

The one thing not to warm is a pipeline whose descriptor depends on a runtime value that is not yet known, such as a swap-chain format read from the canvas after configuration. Those have to wait, which is an argument for configuring the canvas early rather than at first draw.

Failure modes

  • An occasional multi-frame stall with no obvious cause. A pipeline being recompiled, either because useMemo discarded it or because the cache key varies.
  • A validation error at draw time about vertex layout. The layout was omitted from the key, so a cached pipeline built for a different layout was returned.
  • Everything renders washed out on one layer. Blend state omitted from the key.
  • The map goes blank after a device loss and recovery. The cache was not cleared, so it is handing out pipelines belonging to the destroyed device.
  • The cache never hits. The key is being built from an object literal whose property order varies, or includes a value that changes per render — a timestamp, a style object recreated each time.
What a pipeline cache key has to contain A single strip divides a cache key into its six required parts: the shader module identity, the serialised vertex buffer layout, the colour attachment format, the depth format and comparison, the sample count, and the blend state. Omitting any one of them lets the cache return a pipeline that is valid and wrong, which either fails at draw time with a validation error or renders incorrectly with no error at all. KEY PART 0 1 2 3 4 5 6 7 8 9 10 11 shader id vertex layout colour format depth + compare samples blend Key all six required omit any part and the cache returns a pipeline that is valid and wrong Nothing about the data belongs here — row counts and buffers live in bind groups.
The sample count is the part most often forgotten, because most applications have one value for it — until the day a layer gains multisampling and every cached pipeline is suddenly for the wrong target.

Backend / Python interop note

Pipeline compilation is a client-side cost with one server-side lever: the number of distinct pipelines an application needs is a function of how many distinct shapes of data the server sends.

An endpoint that returns one vertex layout for every layer lets the client compile one pipeline. An endpoint that returns whatever layout is convenient per query — sometimes with elevation, sometimes without, sometimes with an extra attribute — forces a pipeline per variant, and the compile cost lands on the first frame that shows each new shape.

There is a second lever in the same place: the number of distinct formats. A server that returns colour as three bytes for one layer and four for another forces two vertex layouts and therefore two pipelines, for a difference the renderer does not care about. Standardising on one record shape across every layer an application serves is usually a change to a single schema definition and removes a whole axis from the cache key.

The practical guidance is to fix the layout at the schema level and pad rather than vary. A record that always carries x, y, z and one attribute, with z zero for two-dimensional data, costs four bytes per row that some layers do not use and saves a pipeline compile plus a branch in every consumer. That is the same trade as the vec4 padding argument on the GPU side, applied one layer up.