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).
// 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".
// 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.
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 inabout:gpuclimbs by one on every quick route change through the map view. Fix: setdisposedinonScopeDisposeand check it after eachawait, destroying the late device inline — exactly theif (disposed) { dev.destroy(); return; }branch above. - Retry storm on intentional teardown. Calling
device.destroy()resolvesdevice.lost; if the handler does not filter reason"destroyed", it re-acquires a device the component no longer wants, sometimes recursively. Detection:statusflickers to"acquiring"immediately after navigating away, and a new device appears post-unmount. Fix: gate the handler ondisposedfirst and oninfo.reason !== "destroyed"second. - Rendering into a lost device. A render loop keeps calling
queue.submit()after a loss, raisingGPUValidationError: Queue of destroyed deviceevery frame. Detection: the console fills with per-frame validation errors and the canvas freezes on the last good frame. Fix: pause the loop whilestatus.value !== "ready"and rebuild resources in awatch(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 thewatch(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.
Related
- Vue Wrapper Patterns for Spatial Components — the
shallowRefreactivity boundary this composable enforces. - Avoiding GPU Buffer Leaks in React Strict Mode — the same asymmetric-teardown hazard on the React side.
- Initializing WebGPU Devices for GIS Workloads — the adapter and limit negotiation this composable wraps.
- Performance Tuning & Profiling for WebGPU Spatial Pipelines — keeping the acquire-to-first-frame budget and post-loss recovery fast.