Managing WebGPU Device Lifecycle in Vue Composables

The exact sub-problem here is owning a GPUDevice across the full span of a Vue component’s life — acquired asynchronously during setup, held outside the reactivity graph while the map renders, torn down deterministically on unmount, and silently rebuilt when the driver drops it mid-session — without ever leaking a handle or submitting to a dead one. A device is not a value you fetch once and forget. requestAdapter() and requestDevice() are both asynchronous, so a fast route change can unmount the component before the device resolves; the device can be lost at any moment when the GPU resets, the tab is backgrounded, or VRAM is exhausted, invalidating every buffer and pipeline built from it; and Vue’s Composition API offers lifecycle hooks whose timing must bracket all of this precisely. Get the ownership wrong and the symptoms are familiar to any spatial dashboard: a <SpatialMap> that keeps a render loop submitting into an orphaned queue after the user navigates away, a device.lost that turns the canvas black with no recovery, or a devtools memory graph that climbs across every panel toggle.

The composable below concentrates that lifecycle into one reusable surface. It holds the device and queue in shallowRef so Vue tracks whether a device exists without proxying the handle — the central reactivity boundary established in the parent Vue wrapper patterns for spatial components reference. It acquires inside onMounted, tears down inside onScopeDispose so cleanup fires whether the composable runs in a component or a detached effectScope, and it subscribes to device.lost to re-acquire transparently. This is the Vue-side counterpart to the React teardown discipline in avoiding GPU buffer leaks in React Strict Mode: the same asymmetric-teardown hazard, solved with the Composition API’s scope primitives instead of an effect’s cleanup return.

Runnable reference implementation

The composable exposes read-only shallowRef handles plus a status state machine, and keeps all imperative control — the disposed flag, the retry counter, the lost-device subscription — private. onScopeDispose is chosen over onUnmounted deliberately: it fires for any reactive scope teardown, so the composable works unchanged inside a component’s setup(), inside a <script setup> block, or nested in a manually created effectScope() that a spatial engine might use to group several composables. The disposed flag is checked after every await because the scope can dispose at any suspension point, and it also distinguishes a genuine device loss (worth retrying) from an intentional destroy() during teardown (never retried).

typescript
// useWebGPUDevice.ts
import { shallowRef, readonly, onMounted, onScopeDispose } from "vue";

export type DeviceStatus =
  | "idle" | "acquiring" | "ready" | "lost" | "unsupported";

interface Options {
  descriptor?: GPUDeviceDescriptor;   // requiredFeatures / requiredLimits
  onReady?: (device: GPUDevice) => void;
  maxRetries?: number;                // re-acquire attempts after a loss
}

export function useWebGPUDevice(options: Options = {}) {
  // shallowRef: Vue tracks reassignment of .value, never walks the handle.
  const device = shallowRef<GPUDevice | null>(null);
  const queue = shallowRef<GPUQueue | null>(null);
  const status = shallowRef<DeviceStatus>("idle");

  const maxRetries = options.maxRetries ?? 3;
  let disposed = false;   // set by onScopeDispose; gates every async branch
  let attempt = 0;

  async function acquire(): Promise<void> {
    if (disposed) return;
    if (!navigator.gpu) { status.value = "unsupported"; return; }
    status.value = "acquiring";

    const adapter = await navigator.gpu.requestAdapter({
      powerPreference: "high-performance",
    });
    if (disposed) return;                       // scope torn down mid-request
    if (!adapter) { status.value = "unsupported"; return; }

    const dev = await adapter.requestDevice(options.descriptor);
    if (disposed) { dev.destroy(); return; }    // late arrival: destroy, no leak

    device.value = dev;
    queue.value = dev.queue;
    status.value = "ready";
    attempt = 0;                                // reset backoff on success
    options.onReady?.(dev);

    // A lost device invalidates every buffer and pipeline built from it.
    void dev.lost.then((info: GPUDeviceLostInfo) => {
      if (disposed) return;                     // normal teardown, not a fault
      device.value = null;
      queue.value = null;
      status.value = "lost";
      // reason "destroyed" means we called destroy() ourselves — do not resurrect.
      if (info.reason !== "destroyed" && attempt < maxRetries) {
        attempt += 1;
        void acquire();                         // transparent re-init
      }
    });
  }

  // Acquire once the component is mounted; onScopeDispose guarantees teardown.
  onMounted(() => { void acquire(); });

  onScopeDispose(() => {
    disposed = true;                            // stop retries and async writes
    device.value?.destroy();                    // fires lost("destroyed"), ignored
    device.value = null;
    queue.value = null;
    status.value = "idle";
  });

  // Manual re-acquire for a UI "retry" button after maxRetries is exhausted.
  function retry(): void {
    if (disposed || status.value === "ready" || status.value === "acquiring") return;
    attempt = 0;
    void acquire();
  }

  return {
    device: readonly(device),
    queue: readonly(queue),
    status: readonly(status),
    retry,
  };
}

A consuming component treats the device as a value that may not exist yet and may vanish and return. Because device is a read-only shallowRef, a watch on it can allocate GPU resources the moment the handle appears and release them when it goes null, which is exactly the seam a lost-and-recovered device needs: the render loop pauses while status is "lost", then rebuilds its buffers against the fresh device on the next "ready".

typescript
// SpatialMap.vue — <script setup>
import { watch } from "vue";
import { useWebGPUDevice } from "./useWebGPUDevice";

const { device, queue, status } = useWebGPUDevice({
  descriptor: { requiredLimits: { maxStorageBufferBindingSize: 128 * 1024 * 1024 } },
});

let pointBuffer: GPUBuffer | null = null;

// Rebuild GPU resources every time a fresh device arrives (initial or post-loss).
watch(device, (dev) => {
  pointBuffer?.destroy();          // release resources from the previous device
  pointBuffer = null;
  if (!dev) return;                // "lost": leave the render loop idle
  pointBuffer = dev.createBuffer({
    label: "spatial-points",
    size: 4 * 1024 * 1024,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
  });
  // ... rebuild pipelines and bind groups against `dev` here ...
});
</script>

The correctness of the whole arrangement rests on the interaction between disposed and device.lost. When the component unmounts, onScopeDispose sets disposed = true and calls dev.destroy(); that destroy resolves the lost promise with reason "destroyed", but the subscription’s first line returns early because disposed is set, so no retry storm is triggered. When the driver drops the device, disposed is false and the reason is not "destroyed", so the subscription nulls the refs and calls acquire() again up to maxRetries times. The two paths never cross, which is what makes teardown deterministic and recovery automatic from the same handler.

Device lifecycle state machine driven by onMounted, device.lost, and onScopeDispose The composable starts in idle. onMounted triggers a transition to acquiring, which requests an adapter and device. On success it moves to ready, where the queue accepts submissions. If navigator.gpu is missing or no adapter is returned, it moves to unsupported. From ready, a driver-side device.lost with a reason other than destroyed moves to lost, which loops back to acquiring for a transparent retry up to the retry limit. From any state, onScopeDispose calls destroy and returns to idle; because the disposed flag is set first, the destroyed loss reason does not trigger a retry. This separates intentional teardown from genuine device loss. idle acquiring ready lost unsupported onMounted device no gpu / adapter lost, reason ≠ destroyed retry < maxRetries onScopeDispose · destroy() The disposed flag is set before destroy(), so the "destroyed" loss reason is filtered out and never retries.
One state machine handles first acquisition, transparent post-loss recovery, and deterministic teardown; the disposed flag is what separates an intentional destroy from a driver-side loss.

Parameter reference

Every tunable and structural choice in the composable, with guidance for Vue-hosted spatial components. These tables scroll horizontally on narrow viewports.

Parameter / choice Value Guidance
device / queue primitive shallowRef Never ref/reactive — deep-proxying a GPUDevice breaks identity checks and taxes every .queue access.
descriptor.requiredLimits e.g. maxStorageBufferBindingSize: 128 MiB Request the ceilings your largest tile needs; unmet limits reject requestDevice() rather than failing later.
maxRetries 2–3 Bounds the re-acquire loop after a loss. Too high spins on an unrecoverable adapter; expose retry() for a manual button past the cap.
Teardown hook onScopeDispose Fires for components and detached effectScope alike; prefer it over onUnmounted for reusable composables.
Acquire hook onMounted Defers acquisition to mount so SSR never touches navigator.gpu; the disposed guard covers an unmount during the async gap.
disposed checks after every await One after requestAdapter() and one after requestDevice(); the second destroys the late device to avoid an orphan.
Lost-reason filter info.reason !== "destroyed" Distinguishes a driver loss (retry) from our own destroy() (stop). Without it, teardown triggers an acquire storm.
powerPreference "high-performance" Selects the discrete GPU on multi-adapter laptops; drop to default for battery-sensitive views.

The onScopeDispose versus onUnmounted choice is the one that bites first: a composable pulled into a shared effectScope (common when a spatial engine groups device, layer, and stream composables) never receives onUnmounted, so a device acquired there would leak on scope teardown. For the authoritative semantics of GPUDevice.lost and its reason values, consult the MDN GPUDevice.lost reference and the W3C WebGPU specification.

Failure modes

  • Orphaned device from unmount during acquisition. The component unmounts while requestDevice() is still pending, so the resolved device is never destroyed. Detection: device count in about:gpu climbs by one on every quick route change through the map view. Fix: set disposed in onScopeDispose and check it after each await, destroying the late device inline — exactly the if (disposed) { dev.destroy(); return; } branch above.
  • Retry storm on intentional teardown. Calling device.destroy() resolves device.lost; if the handler does not filter reason "destroyed", it re-acquires a device the component no longer wants, sometimes recursively. Detection: status flickers to "acquiring" immediately after navigating away, and a new device appears post-unmount. Fix: gate the handler on disposed first and on info.reason !== "destroyed" second.
  • Rendering into a lost device. A render loop keeps calling queue.submit() after a loss, raising GPUValidationError: Queue of destroyed device every frame. Detection: the console fills with per-frame validation errors and the canvas freezes on the last good frame. Fix: pause the loop while status.value !== "ready" and rebuild resources in a watch(device, ...) so submission resumes only against a live device. Where re-acquisition keeps failing, hand off to the browser support and fallback routing strategies and the WebGL2 path.
  • Stale buffers after recovery. After a loss and successful re-acquire, buffers and pipelines from the old device are bound to the new one, which rejects them because resources are device-scoped. Detection: GPUValidationError: Bind group ... created from a different device. Fix: destroy and reallocate every resource inside the watch(device, ...) callback; treat a new device handle as a full rebuild, laying out uniforms per memory alignment for spatial data buffers and re-creating the compute and render pipelines from scratch.

Up: Vue Wrapper Patterns for Spatial Components