Handling Device Lost and Recreating GIS Resources
A GPUDevice can be lost at any moment — a driver reset, a background tab discarded under memory pressure, an external GPU unplugged, or the application’s own call to destroy(). When it happens, every buffer, texture, pipeline and bind group created from that device becomes invalid at once, and a map that treats the loss as a crash simply disappears. Recovery is mechanical if the application was built for it and close to impossible if it was not, because it requires knowing what to rebuild and in what order. This page is that recovery path, the one loss reason that must not be retried, and the resource registry that makes rebuilding a single function. It is one stage of initializing WebGPU devices for GIS workloads.
Runnable reference implementation
device.lost is a promise that resolves — never rejects — exactly once. The reason distinguishes a deliberate teardown from an involuntary one, and the two need opposite responses.
type Rebuild = (device: GPUDevice) => void | Promise<void>;
class DeviceHost {
private device: GPUDevice | null = null;
private readonly rebuilders: Rebuild[] = [];
private reacquiring = false;
/** Every subsystem registers how to rebuild itself, once, at start-up. */
onRebuild(fn: Rebuild): void {
this.rebuilders.push(fn);
}
async acquire(): Promise<GPUDevice> {
const adapter = await navigator.gpu?.requestAdapter({
powerPreference: "high-performance",
});
if (!adapter) throw new Error("no adapter — route to the fallback renderer");
const device = await adapter.requestDevice({
label: "spatial-device",
requiredLimits: { maxBufferSize: 256 * 1024 * 1024 },
});
this.device = device;
void device.lost.then((info) => {
// "destroyed" means we asked for this. Re-acquiring would resurrect a
// map the application deliberately tore down.
if (info.reason === "destroyed") return;
void this.reacquire();
});
return device;
}
private async reacquire(): Promise<void> {
if (this.reacquiring) return; // one recovery at a time
this.reacquiring = true;
try {
const device = await this.acquire();
for (const rebuild of this.rebuilders) await rebuild(device);
} finally {
this.reacquiring = false;
}
}
}
Every subsystem — the tile cache, the layer renderers, the profiler — registers one rebuild function and nothing else. That is what keeps recovery to a single code path: the host does not know what a tile cache is, and the tile cache does not know that a device was lost, only that it has been handed a new one and must rebuild against it.
Parameter reference
| Value | Setting here | Guidance |
|---|---|---|
Re-acquire on "destroyed" |
never | The application asked for the teardown. Retrying fights it, and the symptom is a device count that only grows. |
Re-acquire on "unknown" |
yes, once | Driver reset or tab discard. A single attempt, then the fallback path if it fails. |
| Concurrent recoveries | one | A guard flag; several subsystems noticing the loss must not each start a recovery. |
| Rebuild order | registration order | Register the device-level resources first so later rebuilders can depend on them. |
| Backoff before re-acquiring | 500 ms | A driver that has just reset may not be ready; the polling loop already implements the schedule. |
What survives a loss, and what does not
The distinction that makes recovery tractable is between GPU-side state, which is gone, and CPU-side state, which is not.
Gone: every GPUBuffer, GPUTexture, GPUSampler, GPUBindGroup, GPUShaderModule, GPURenderPipeline and GPUComputePipeline. Also the capability record, because the new device may have different limits — a laptop that switched from discrete to integrated graphics on battery hands back a materially weaker device, and every buffer size derived from the old record is now wrong.
Not gone: the tile cache’s index, the decoded tile bytes if they were kept, the camera, the layer configuration, the fetch queue, and every piece of application state. This is the reason a good recovery is fast: the expensive part of loading a map is the network and the decode, and neither has to happen again.
The practical consequence is a rule about where decoded data lives. A pipeline that uploads a tile and immediately drops the source bytes has to refetch on recovery; one that keeps the bytes in an in-memory cache can rebuild the whole scene without a single request. Keeping them costs host memory, which is far cheaper than VRAM, and turns a device loss from a visible reload into a barely perceptible flicker.
Testing recovery before it happens in production
Device loss is rare enough in development that a recovery path written once and never exercised is a recovery path that does not work. Two ways of provoking it deliberately are worth wiring into a development build.
The first is device.destroy(). It resolves device.lost with reason "destroyed", which exercises the branch that must not re-acquire — the one whose failure mode is an infinite loop. Binding it to a key in a debug build takes a minute and catches the loop immediately.
The second is harder and more valuable: exercising the "unknown" branch. There is no standard way to force it, but the practical substitute is a fault-injection flag in the DeviceHost that synthesises the same recovery — tear down the current device, acquire a new one, run every rebuilder — without waiting for the driver to cooperate. It is not a perfect simulation, because a real loss also invalidates objects the application may still be holding, but it exercises the rebuild order and catches the two most common bugs: a rebuilder that captured the old device in a closure, and a layer that never registered one at all.
Both belong behind a flag rather than in tests, because the assertion that matters is visual. The map should come back with the same tiles, the same camera and no visible gap, and that is something a person confirms in a second and a test suite struggles to express.
Failure modes
- The map disappears and never returns. Nothing listened to
device.lost. Detection: the console shows validation errors referencing a destroyed device, and they stop when the tab is reloaded. - Two devices exist after a recovery. Several subsystems each started a recovery. Fix: the guard flag, and one owner for the device.
- Recovery loops forever. The application called
destroy()and the handler re-acquired, which produced a device that was then destroyed again. Fix: branch onreason === "destroyed". - The map returns but is missing a layer. That layer registered no rebuilder, or registered one that captured the old device in a closure. Fix: rebuilders take the new device as a parameter and must never reference the outer one.
- Buffer sizes fail on the rebuilt device. The capability record was reused rather than rebuilt, and the new adapter is weaker. Fix: rebuild the record first and derive every size from it.
What the user should see
Recovery is also a user-interface decision, and the default — say nothing and rebuild — is usually right.
A device loss that recovers in a few hundred milliseconds is best handled silently. The map goes blank for a frame or two and comes back with the same view; showing an error toast for that is worse than showing nothing, because it draws attention to a fault the user would otherwise not have registered. Log it, count it, and move on.
A recovery that takes longer — because tiles have to be refetched, or because the driver is slow to hand back a device — deserves the same treatment a slow initial load gets: keep the last frame on screen rather than clearing to blank, and let the tiles fill in as they arrive. That is a matter of not destroying the canvas contents, which is the default behaviour, and of not clearing application state that the recovery does not need to discard.
A recovery that fails, twice, is the case that needs telling. At that point the honest response is the fallback renderer described under browser support and fallback routing, with a one-line note that hardware acceleration is unavailable — because the map the user then sees is genuinely a different, more limited thing, and pretending otherwise makes its limits look like bugs.
Backend / Python interop note
Device loss is a client-side event, but it has one server-side consequence worth planning for: a recovery that has to refetch will do so as a burst, and a burst from many clients at once — a driver update rolling out across a fleet, for instance — looks exactly like a traffic spike.
Two things make that harmless. The first is idempotent, cacheable tile URLs, so a refetch after recovery is served from the browser’s HTTP cache rather than from the origin; the same property that makes VRAM eviction cheap makes device recovery cheap. The second is to keep decoded bytes on the host, which removes the refetch entirely for anything already loaded.
For a Python tile service the practical guidance is unchanged from ordinary caching hygiene — immutable URLs with a version in the path, long max-age, and an ETag — but it is worth noting that this is the case where it matters most, because it converts a fleet-wide recovery event from a thundering herd into a no-op.
Related
- Initializing WebGPU devices for GIS workloads — the topic this page belongs to.
- Setting up WebGPU device polling for GIS apps — the retry schedule the re-acquire path reuses.
- Requesting optional features for spatial workloads — what the rebuilt capability record has to re-negotiate.
- Avoiding GPU buffer leaks in React strict mode — the same registry idea, applied to a component lifecycle.
- Managing WebGPU device lifecycle in Vue composables — the same branch on the loss reason, in a composable.