Avoiding GPU Buffer Leaks in React Strict Mode
The exact sub-problem here is asymmetric teardown under React’s development-only double-invoke. In React 18 and 19, <StrictMode> deliberately runs every effect twice on mount — setup, cleanup, setup — to surface effects that are not resilient to remounting. For a component that allocates a GPUBuffer, a GPUTexture, or an entire GPUDevice inside useEffect, that extra cycle turns a single logical allocation into two physical ones, and any handle that is created but not destroyed in the matching cleanup is now orphaned VRAM the JavaScript garbage collector cannot reclaim, because a GPUBuffer owns driver-side memory that only destroy() releases. The failure is worse than a doubled allocation. WebGPU device acquisition is asynchronous, so the common shape — requestAdapter() then requestDevice() then createBuffer() inside an async function — resolves after the cleanup for that effect run has already fired. The buffer lands in a ref whose cleanup has come and gone, and nothing will ever call destroy() on it. Over a few hot-reload cycles a map view leaks tens of megabytes; over a long-lived dashboard that mounts and unmounts panels, it exhausts the adapter’s storage budget and ends in a device.lost event.
The naive guard — a hasInitialized ref that skips the second setup — makes the leak worse, not better: the first cleanup still destroys the resources, the second setup skips recreation because the guard is set, and the component is left holding dead handles that raise GPUValidationError: Destroyed buffer used in a submit on the next frame. The correct fix is not to suppress the double-invoke but to make allocation and disposal perfectly symmetric and idempotent, so running the cycle any number of times — twice in StrictMode, once in production, or interleaved with an async race — always ends with every allocated handle destroyed exactly once. This page is the teardown-discipline companion to React state hydration for GPU contexts, whose ref-held context is the very thing that must survive re-render yet still be reclaimed on unmount.
Runnable reference implementation
The pattern is a disposal scope: a small object that collects teardown callbacks and runs them once, in reverse order, and that disposes any resource registered after it has already closed. That last property is what defeats the async race — when requestDevice() resolves after cleanup has run, registering the device destroys it on the spot instead of leaking it. The scope lives in a useRef so it is stable across re-renders, and the useEffect cleanup is the single, idempotent teardown path. GPU objects that own VRAM (GPUBuffer, GPUTexture, GPUQuerySet, GPUDevice) expose destroy(); pipelines and shader modules are garbage-collected and need no explicit release, so the scope also accepts a plain callback for any non-destroy cleanup such as unsubscribing from device.lost.
// gpu-scope.ts — collects teardown, runs it once, disposes late arrivals.
interface Destroyable {
destroy(): void;
}
export class GpuScope {
private cleanups: Array<() => void> = [];
private closed = false;
// Track a VRAM-owning handle (buffer, texture, querySet, device).
// Returns the handle so calls can wrap createBuffer() inline.
track<T extends Destroyable>(resource: T): T {
this.defer(() => resource.destroy());
return resource;
}
// Register any teardown callback (event unsubscribe, unmap, abort).
defer(cleanup: () => void): void {
if (this.closed) {
cleanup(); // late arrival after cleanup ran — dispose now
return;
}
this.cleanups.push(cleanup);
}
get isClosed(): boolean {
return this.closed;
}
// Idempotent: safe to call from an effect cleanup that fires twice.
dispose(): void {
if (this.closed) return;
this.closed = true;
// LIFO: destroy buffers before the device that backs them.
for (let i = this.cleanups.length - 1; i >= 0; i--) {
this.cleanups[i]();
}
this.cleanups.length = 0;
}
}
The hook wires the scope into the effect lifecycle. Every await in the async initializer is followed by a scope.isClosed check, because cleanup can run at any suspension point; if the scope has closed, the initializer bails and any handle it already produced is destroyed by track. Nothing is stored in a render-scoped variable — the device and buffers live behind the scope, and the component reads them through a ref only after init signals readiness.
// useSpatialGpu.ts — one device + one storage buffer, leak-free under StrictMode.
import { useEffect, useRef, useState } from "react";
import { GpuScope } from "./gpu-scope";
interface GpuContext {
device: GPUDevice;
pointBuffer: GPUBuffer;
}
export function useSpatialGpu(pointCapacity: number) {
const ctxRef = useRef<GpuContext | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const scope = new GpuScope();
const init = async () => {
if (!navigator.gpu) return; // no WebGPU: caller falls back
const adapter = await navigator.gpu.requestAdapter();
if (!adapter || scope.isClosed) return; // cleanup already ran
const device = await adapter.requestDevice();
if (scope.isClosed) { // race: dispose the orphan
device.destroy();
return;
}
scope.track(device); // device owns everything below
// A device-lost subscription is a teardown, not a VRAM handle:
// register it so the listener does not fire into a dead component.
let alive = true;
scope.defer(() => { alive = false; });
device.lost.then((info) => {
if (alive) console.warn(`device lost: ${info.reason}`);
});
// 16-byte aligned per-point vec2<f32>; storage buffer is destroyable.
const pointBuffer = scope.track(device.createBuffer({
label: "spatial-points",
size: pointCapacity * 2 * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
}));
ctxRef.current = { device, pointBuffer };
setReady(true);
};
void init();
// The ONLY teardown path. Idempotent, so StrictMode's double cleanup
// and a real unmount both converge on exactly-once disposal.
return () => {
scope.dispose();
ctxRef.current = null;
setReady(false);
};
}, [pointCapacity]);
return { ctxRef, ready };
}
Two invariants make this correct under every ordering. First, dispose() short-circuits on its closed flag, so the StrictMode sequence setup → cleanup → setup → (unmount) cleanup disposes each scope instance once and never touches a handle twice. Second, because each effect run constructs a fresh GpuScope, the first run’s resources are destroyed by the first run’s cleanup and the second run allocates its own independent set — there is no shared mutable “did we init” flag to get out of sync. In production, where the effect runs once, the same code path allocates once and disposes once on unmount. The device is tracked first and disposed last, so even a partial init (adapter obtained, device created, buffer allocation about to run when cleanup fires) tears down cleanly: destroying the device implicitly invalidates any child resource, and the explicit LIFO order handles the case where the device is shared and must outlive the buffers.
Parameter reference
Every tunable and structural choice in the implementation above, with guidance for spatial React apps. These tables scroll horizontally on narrow viewports.
| Parameter / choice | Value | Guidance |
|---|---|---|
pointCapacity |
dataset-dependent | Drives size = pointCapacity * 2 * 4 for vec2<f32>. Because it is in the effect dependency array, changing it disposes and reallocates — keep it stable per view, not per frame. |
Buffer usage |
STORAGE | COPY_DST |
Match the access pattern; a VERTEX-only buffer bound to a storage slot raises a validation error unrelated to the leak. |
scope.isClosed checks |
after every await |
One immediately after requestAdapter() and one after requestDevice(). Omitting either reopens the async-race leak. |
| Disposal order | LIFO (device last) | The device is tracked first so it is destroyed last; child buffers are released before their backing device. |
device.lost subscription |
via scope.defer |
Registered as a callback, not a VRAM handle. The alive flag stops the listener firing into an unmounted component. |
| Effect dependency array | [pointCapacity] |
Empty [] for a mount-once device; include any value whose change must rebuild the context. Every dependency change is a full dispose/reallocate. |
Handles needing destroy() |
buffers, textures, querySets, device | Pipelines, bind groups, and shader modules are garbage-collected — do not call destroy() on them; there is no such method. |
The isClosed guard after each await is the constraint that bites first: skip it and the code passes every unit test (which does not run StrictMode’s double-invoke against a real adapter) yet leaks one device per mount in the browser. For the authoritative lifecycle of GPUDevice.destroy() and buffer destruction semantics, consult the W3C WebGPU specification and the MDN GPUBuffer.destroy() reference.
Failure modes
- Orphaned device from the async race.
requestDevice()resolves after the effect cleanup has fired, so the device is stored but never destroyed. Detection: the browser’sabout:gpu(or adevice.lostcascade) shows device count climbing by one per hot reload; VRAM in the task manager rises monotonically. Fix: checkscope.isClosedafter eachawaitand destroy the handle inline, exactly as the reference does — the scope’s late-arrivaldeferbranch handles it automatically once the resource is registered throughtrack. - Dead-handle reuse from a
hasInitializedguard. A boolean ref that skips the second StrictMode setup leaves the component holding buffers the first cleanup already destroyed. Detection:GPUValidationError: Destroyed buffer ... used in submiton the first frame after mount, only in development. Fix: delete the guard; make disposal idempotent instead of suppressing re-initialization, so the second setup allocates a fresh, independent scope. - Double
destroy()on shared resources. Two effects (or a parent and child) both track the same device and each dispose it, raisingCannot destroy an already-destroyed deviceor silently invalidating live work. Detection: intermittentdevice.lostwith reason"destroyed"while the UI is still mounted. Fix: give each resource exactly one owning scope; pass shared handles down as props and let only the allocating component’s scope destroy them. - Listener leak surviving unmount. A
device.lost.then(...)orqueue.onSubmittedWorkDone()callback captures component state and fires after teardown, throwing on a null ref. Detection:Cannot read properties of nullin a promise callback seconds after navigating away. Fix: register analiveflag throughscope.deferand gate every async callback on it, as shown. When reacquisition after a genuine device loss is required, route it through the browser support and fallback routing strategies and rebuild the context on the device from initializing WebGPU devices for GIS workloads.
Related
- React State Hydration for GPU Contexts — the ref-held context whose lifecycle this disposal scope protects.
- Managing WebGPU Device Lifecycle in Vue Composables — the same teardown discipline expressed with
onScopeDispose. - WebGPU Compute vs Render Pipeline Fundamentals — why pipelines are garbage-collected while buffers need explicit
destroy(). - Performance Tuning & Profiling for WebGPU Spatial Pipelines — measuring the VRAM baseline that a leak-free mount keeps flat, including an LRU VRAM cache for tile buffers.