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.
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.
/** 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.
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. |
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
useMemodiscarded 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.
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.
Related
- React state hydration for GPU contexts — the topic this page belongs to.
- Avoiding GPU buffer leaks in React strict mode — the lifecycle discipline this depends on.
- Handling device lost and recreating GIS resources — the one event that invalidates the cache.
- WebGPU compute vs render pipeline fundamentals — what a pipeline descriptor actually contains.
- Framework integration and backend synchronization — the section this sits in.