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.

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

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

StrictMode double-invoke timeline with a disposal scope catching the async race A development mount under React StrictMode runs three effect calls in order along a timeline: setup one constructs scope A and starts async device init, cleanup one disposes scope A and marks it closed, then setup two constructs a fresh scope B and starts its own init. When the async init started by setup one finally resolves, it calls track on the already-closed scope A, which destroys the late buffer immediately rather than leaking it. The init started by setup two resolves into the still-open scope B, whose resources stay live until unmount. The result is that every allocated handle is destroyed exactly once. StrictMode dev mount — effect runs twice time setup #1 new GpuScope A · init() cleanup #1 scope A.dispose() · closed setup #2 new GpuScope B · init() async requestDevice() resolutions init #1 resolves scope A closed → track() destroys now ✓ no leak init #2 resolves scope B open → resources stay live On real unmount: scope B.dispose() runs once — every handle destroyed exactly once. A fresh scope per effect run means no shared "did-init" flag can desynchronize allocation from disposal.
Each effect run owns an independent scope; a late async resolution into a closed scope destroys its handle immediately, so neither the StrictMode double-invoke nor the async race leaks VRAM.

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’s about:gpu (or a device.lost cascade) shows device count climbing by one per hot reload; VRAM in the task manager rises monotonically. Fix: check scope.isClosed after each await and destroy the handle inline, exactly as the reference does — the scope’s late-arrival defer branch handles it automatically once the resource is registered through track.
  • Dead-handle reuse from a hasInitialized guard. 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 submit on 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, raising Cannot destroy an already-destroyed device or silently invalidating live work. Detection: intermittent device.lost with 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(...) or queue.onSubmittedWorkDone() callback captures component state and fires after teardown, throwing on a null ref. Detection: Cannot read properties of null in a promise callback seconds after navigating away. Fix: register an alive flag through scope.defer and 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.

Up: React State Hydration for GPU Contexts