VRAM Budget Management Across Tile Zoom Levels
A tiled basemap is a memory amplifier. Each web-map zoom level holds four times as many tiles as the level above it, and a single interaction — a pinch-zoom that crosses a level boundary, or a flick-pan across a dense metro — asks the renderer to make dozens of new tile buffers resident within one frame while the old level’s buffers are still live. Left unmanaged, the GPU-side working set grows without bound: the device holds vertex, index, and attribute buffers for every tile the camera has ever touched, createBuffer latency creeps upward as the driver heap fills, and eventually a large allocation trips a GPUDevice.lost event mid-pan even though the tile data itself is only a few hundred megabytes. The work this area covers is putting a hard ceiling on that growth: estimating what one tile costs in VRAM, defining a working-set budget in bytes, evicting the least-recently-used tiles when the budget is exceeded, timing GPUBuffer.destroy() so it never races an in-flight pass, and recycling buffer allocations through a pool so the driver allocator never sees the churn.
This is one stage of Performance Tuning & Profiling for WebGPU Spatial Pipelines. Budgeting sits alongside profiling: you cannot cap what you have not measured, so the per-tile cost model here is calibrated against the same GPU timings you gather in frame profiling with timestamp queries. The eviction and pooling machinery assumes buffers laid out to the stride rules in memory alignment for spatial data buffers, and it reuses the size-bucketed allocation idea proven for density grids in spatial aggregation in GPU memory. Two deeper guides finish the job: a runnable cache for the eviction policy, and a footprint calculator for the budget itself.
Prerequisites
Before wiring a budget into a tiled renderer, you should have:
- A valid
GPUDevicewhoselimitsyou have already read — in particularmaxBufferSizeandmaxStorageBufferBindingSize, which cap any single tile allocation. Adapter negotiation is covered in WebGPU device initialization for GIS workloads. - A stable tile identity. Every tile needs a canonical key — the
z/x/ytriple is the natural one — so the cache can look a tile up, mark it recently used, and evict it. Two code paths that build the same tile must produce the same key or the cache double-allocates. - A per-tile buffer layout with known strides. The footprint model multiplies a tile’s vertex, index, and attribute byte counts; those counts are only stable if the buffers are packed to fixed strides, which is the subject of memory alignment for spatial data buffers.
- A measured frame cost per tile. A budget expressed only in bytes ignores the upload and draw time each resident tile adds; calibrate the byte ceiling against the GPU timings from frame profiling with timestamp queries so the working set fits the frame, not only the heap.
- A viewport-to-tiles function. Given the camera centre, zoom, and canvas size you must be able to enumerate the tiles currently in view; that set is the floor the cache may never evict below.
API and limits reference
The fields and limits below govern every buffer the tile cache holds and every eviction decision it makes. The device limits are the constraints that bite first when a deep zoom balloons the working set.
| Field / rule | Value | Why it matters for a tile budget |
|---|---|---|
maxBufferSize |
≥ 256 MiB (default) | Caps one tile’s largest buffer. A single dense vector tile’s vertex buffer must stay under this or createBuffer throws; split oversized tiles. |
maxStorageBufferBindingSize |
≥ 128 MiB (default) | Caps a storage buffer bound in one bind group. A merged multi-tile buffer can approach it; request a higher limit or bind per tile. |
GPUBufferUsage per tile |
VERTEX | INDEX | COPY_DST |
COPY_DST receives the upload; add STORAGE only if a compute pass reads the tile. Fewer usages let the driver place the buffer more freely. |
GPUBuffer.destroy() |
frees VRAM at call | The only way to release a tile’s memory; must be called after the last submit that reads the buffer resolves, never during an in-flight pass. |
queue.onSubmittedWorkDone() |
Promise<void> |
Resolves when queued work finishes; gate destroy() on it so eviction never races a draw reading the evicted tile. |
minStorageBufferOffsetAlignment |
256 (default) | Sub-allocation offsets inside a pooled buffer must be a multiple; misalignment raises GPUValidationError at bind time. |
| Working-set ceiling | device-dependent | Not a WebGPU limit — a self-imposed byte budget, typically 40–60% of estimated VRAM, leaving headroom for textures and the swap chain. |
| High / low watermark | e.g. 0.9 / 0.7 of ceiling | Eviction starts at the high watermark and runs until the low one, so the cache does not thrash one tile at a time on every admit. |
These tables scroll horizontally on narrow viewports. For authoritative wording on buffer limits, usage flags, and the destroy lifecycle, consult the W3C WebGPU specification and the GPUBuffer.destroy() reference on MDN.
Implementation walkthrough
The budget is a loop wrapped around ordinary tile rendering: estimate the ceiling once, admit tiles on demand as the camera moves, mark each drawn tile as recently used, and when the resident total crosses the high watermark evict from the least-recently-used end until it drops below the low watermark. Freed buffers are recycled through a pool rather than destroyed and reallocated at pan cadence.
Step 1 — Fix a byte ceiling and per-tile cost
The ceiling is a single number derived from the device, not a guess per frame. Estimate available VRAM conservatively, take a fraction of it for tiles, and precompute the byte cost of one tile from its buffer strides. Deriving the full per-zoom footprint from these primitives is the subject of estimating VRAM footprint per zoom level; here we only need the ceiling and the single-tile cost.
interface TileCost {
vertexBytes: number; // positions + attributes, aligned to their stride
indexBytes: number; // triangle or line indices
uniformBytes: number; // per-tile transform / style, 256-byte aligned
}
// One tile's resident footprint, rounded up to the allocation granularity so
// the estimate matches what the driver actually reserves.
function tileFootprint(c: TileCost, allocGranularity = 256): number {
const align = (n: number) => Math.ceil(n / allocGranularity) * allocGranularity;
return align(c.vertexBytes) + align(c.indexBytes) + align(c.uniformBytes);
}
// A conservative ceiling: a fraction of estimated VRAM, leaving headroom for
// textures, the swap chain, and driver overhead we cannot see from WebGPU.
function vramCeiling(estimatedVramBytes: number, tileFraction = 0.5): number {
return Math.floor(estimatedVramBytes * tileFraction);
}
Rounding each buffer up to the allocation granularity is the spatial-data-specific correction: a tile whose index buffer is 300 bytes still consumes a full aligned block, and ignoring that undercounts a deep-zoom working set by tens of megabytes.
Step 2 — Enumerate the resident working set
The floor of the budget is the set of tiles in view plus a prefetch ring one tile wide, so a pan does not stall waiting for uploads. This set may never be evicted; only tiles outside it are eviction candidates.
interface TileId { z: number; x: number; y: number; }
const keyOf = (t: TileId): string => `${t.z}/${t.x}/${t.y}`;
// Tiles covering the viewport at the current zoom, expanded by a one-tile ring.
function workingSet(
center: TileId,
cols: number, // viewport width in tiles, rounded up
rows: number, // viewport height in tiles, rounded up
ring = 1,
): TileId[] {
const out: TileId[] = [];
const half = { x: Math.ceil(cols / 2), y: Math.ceil(rows / 2) };
const span = 1 << center.z; // tiles per axis at this zoom
for (let dx = -half.x - ring; dx <= half.x + ring; dx++) {
for (let dy = -half.y - ring; dy <= half.y + ring; dy++) {
const x = center.x + dx;
const y = center.y + dy;
if (x < 0 || y < 0 || x >= span || y >= span) continue; // clamp to world bounds
out.push({ z: center.z, x, y });
}
}
return out;
}
Clamping to 1 << z world bounds rather than wrapping is the map-specific choice: emitting a negative or out-of-range tile key admits a tile that will never draw, silently wasting a cache slot on every pan near the antimeridian or a pole.
Step 3 — Admit tiles and mark recency
Every frame, ensure the working set is resident and touch each tile so the recency order reflects what the camera is actually looking at. Admission uploads a tile’s buffers once; a subsequent touch is O(1) and only reorders the list. The full data structure — a map plus a doubly linked recency list — lives in building an LRU VRAM cache for tile buffers; this step shows the admit path.
interface ResidentTile {
buffers: { vertex: GPUBuffer; index: GPUBuffer; uniform: GPUBuffer };
bytes: number; // tileFootprint() for this tile
lastUsed: number; // monotonic tick, bumped on every touch
}
class WorkingSetCache {
private resident = new Map<string, ResidentTile>();
private residentBytes = 0;
private tick = 0;
constructor(private device: GPUDevice, private ceiling: number) {}
// Make a tile resident if absent, and mark it most-recently-used either way.
admit(id: TileId, build: () => ResidentTile): ResidentTile {
const key = keyOf(id);
let t = this.resident.get(key);
if (!t) {
t = build(); // uploads buffers via writeBuffer
this.resident.set(key, t);
this.residentBytes += t.bytes; // account before any eviction check
}
t.lastUsed = ++this.tick; // touch: newest recency stamp
return t;
}
}
Accounting residentBytes at admit time — before the eviction check in Step 4 — is what keeps the ceiling honest: if you subtract on evict but forget to add on admit, the counter drifts and the budget silently stops binding.
Step 4 — Evict least-recently-used past the high watermark
When the resident total crosses the high watermark, evict the oldest tiles that are not in the current working set until the total drops below the low watermark. Evicting to the low mark rather than to exactly the ceiling prevents thrashing, where a single admit forces a single evict every frame.
class WorkingSetCache {
// ... fields from Step 3 ...
evictToBudget(protectedKeys: Set<string>, high = 0.9, low = 0.7): void {
if (this.residentBytes <= this.ceiling * high) return; // under pressure? do nothing
const target = this.ceiling * low;
// Oldest first, skipping the protected working set that must stay resident.
const candidates = [...this.resident.entries()]
.filter(([k]) => !protectedKeys.has(k))
.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
for (const [key, t] of candidates) {
if (this.residentBytes <= target) break;
this.resident.delete(key);
this.residentBytes -= t.bytes;
// destroy() is deferred to Step 5, never called inline here.
this.retire(t);
}
}
private retire(_t: ResidentTile): void { /* Step 5 */ }
}
Filtering out the protected working set before sorting is the correctness guard: an aggressive low watermark could otherwise evict a tile the camera is currently drawing, producing a one-frame hole in the map exactly where the user is looking.
Step 5 — Time destroy() and recycle the allocation
GPUBuffer.destroy() frees VRAM immediately, so calling it on a buffer a queued pass still reads is a use-after-free the validator reports as a destroyed-resource error. Defer destruction behind queue.onSubmittedWorkDone(), and rather than destroying at all, return the buffer to a size-bucketed pool so the next tile of the same size reuses it — the recycling pattern proven for density grids in reducing GPU memory fragmentation during spatial aggregation.
class WorkingSetCache {
private pool = new Map<number, GPUBuffer[]>(); // keyed by aligned byte size
private pendingRetire: ResidentTile[] = [];
private retire(t: ResidentTile): void {
this.pendingRetire.push(t); // hold until the GPU is done with it
}
// Call once per frame after submit: safe point to recycle evicted buffers.
async reclaim(): Promise<void> {
if (this.pendingRetire.length === 0) return;
const batch = this.pendingRetire;
this.pendingRetire = [];
await this.device.queue.onSubmittedWorkDone(); // no pass still reads these
for (const t of batch) {
for (const buf of [t.buffers.vertex, t.buffers.index, t.buffers.uniform]) {
const bucket = this.pool.get(buf.size) ?? [];
bucket.push(buf); // recycle instead of destroy()
this.pool.set(buf.size, bucket);
}
}
}
// Reuse a pooled buffer of the right size, or allocate once if none free.
private take(size: number, usage: GPUBufferUsageFlags): GPUBuffer {
const bucket = this.pool.get(size);
return bucket?.pop() ?? this.device.createBuffer({ size, usage });
}
}
Gating reclamation on onSubmittedWorkDone is the timing that makes eviction safe: it guarantees no in-flight submission still references the buffer before it is either reused or destroyed, which is the difference between a smooth pan and an intermittent device loss.
Memory and performance implications
The whole point of the budget is that VRAM scales with the viewport, not with the camera’s history. A working set of the visible tiles plus a one-tile ring is roughly (cols + 2)(rows + 2) tiles regardless of zoom; at a 1600×1200 canvas over 256-pixel tiles that is about 9 × 7 = 63 tiles, so a 400 KiB tile costs about 25 MiB resident. Without eviction, a two-minute pan session across a city can touch several thousand tiles and hold every one, crossing 1 GiB and losing the device; with it, the footprint stays flat at that 25 MiB plus whatever slack the low watermark leaves. Because each web-map level holds four times the tiles of the one above, the danger is concentrated at zoom transitions: crossing from z to z+1 momentarily wants both levels resident, so size the ceiling to hold at least the union of two adjacent levels’ working sets or the transition frame will thrash.
The dominant runtime cost is upload, not eviction bookkeeping. Making a tile resident copies its vertex and index buffers across the bus with writeBuffer; at 400 KiB per tile and a dozen new tiles on a fast flick, that is several megabytes per frame competing with the render for bus bandwidth. Two levers control it: the prefetch ring amortizes the cost by admitting a tile a frame before it is needed, and buffer pooling removes the allocation half of the cost entirely, since createBuffer on a fresh allocation is materially slower than popping a recycled one off the pool. Recycling also keeps the driver heap from fragmenting across zoom levels, the failure the fragmentation reference covers in depth. Whether a given tile’s draw actually fits the frame — as opposed to merely fitting the byte budget — is a question only measurement answers, which is why the ceiling should be calibrated against the pass durations from frame profiling with timestamp queries.
The eviction policy’s own cost is a sort over the resident set when the high watermark is crossed. At sixty-odd resident tiles that sort is negligible, but a naive re-sort every frame is wasteful; the doubly linked recency list in building an LRU VRAM cache for tile buffers makes both touch and evict O(1) so the policy never shows up in a frame profile.
Failure modes and diagnostics
- Out of memory on a large tile allocation. A dense vector tile whose vertex buffer exceeds
maxBufferSize, or a resident set that crosses the ceiling because eviction never ran, makescreateBufferthrow a range error or the driver reject the allocation. Detection: the throw is synchronous atcreateBuffer, orresidentBytesclimbs past the ceiling in your own accounting. Fix: split oversized tiles into multiple buffers, and runevictToBudgeton every admit, not only on zoom change. - Device lost from a TDR watchdog. Letting the working set grow unbounded eventually forces the driver to spill or fails a large allocation mid-pan, and an oversized upload or draw can trip the OS timeout-detection-and-recovery watchdog, invalidating every buffer and pipeline at once. Detection:
device.lostresolves with a reason string. Fix: enforce the ceiling so the resident set never balloons, cap per-frame upload volume, and recreate the device and rebuild the pool through the browser support fallback routing strategies. - Use-after-destroy on an evicted tile. Calling
GPUBuffer.destroy()inline in the eviction loop frees a buffer a still-queued render pass reads, and the validator reports a destroyed-resource error on the next submit. Detection: aGPUValidationErrornaming a destroyed buffer, often one frame after a fast pan. Fix: defer destruction behindqueue.onSubmittedWorkDone()as in Step 5, and never destroy a tile still in the current working set. - Heap fragmentation from realloc churn. Destroying and recreating differently sized tile buffers every zoom level leaves the driver heap pocked with gaps, so
createBufferlatency climbs even though total free VRAM looks sufficient. Detection: rising allocation latency frame over frame with stable resident bytes. Fix: recycle through the size-bucketed pool instead of destroy/recreate, following reducing GPU memory fragmentation during spatial aggregation. - Eviction thrash at the ceiling. Evicting to exactly the ceiling instead of down to a low watermark forces one evict per admit, destroying and reuploading the same border tiles every frame during a steady pan. Detection: a sawtooth in resident bytes and a spike in per-frame
writeBuffervolume. Fix: evict to the low watermark so each eviction pass reclaims a batch, leaving slack before the next one triggers. - Silently wrong footprint from stride drift. Estimating a tile’s cost from logical vertex count rather than the aligned buffer stride undercounts VRAM, so the ceiling admits more tiles than fit. Detection: real VRAM use exceeds the accounted
residentBytes. Fix: derivetileFootprintfrom the actual aligned strides per memory alignment for spatial data buffers, and round every buffer up to the allocation granularity.
Continue in this section
- Building an LRU VRAM Cache for Tile Buffers — a complete, runnable cache keyed by tile id that holds
GPUBuffers, tracks recency in O(1), and evicts and destroys the least-recently-used tile under watermark pressure. - Estimating VRAM Footprint Per Zoom Level — the formulas and TypeScript that turn tiles-in-view and per-tile buffer sizes into an expected resident-bytes number for every zoom band.
Related
- Performance Tuning & Profiling for WebGPU Spatial Pipelines — the wider set of profiling and tuning guides this budgeting work belongs to.
- Frame Profiling with Timestamp Queries — the GPU timings that calibrate the byte ceiling against the frame it must fit.
- Reducing GPU Memory Fragmentation During Spatial Aggregation — the size-bucketed pool that recycles evicted tile buffers without fragmenting the heap.
- Spatial Aggregation in GPU Memory — resolution-scaled VRAM budgeting for density grids, the sibling pattern to per-tile budgeting.
- Memory Alignment for Spatial Data Buffers — the stride rules that make a per-tile footprint estimate match real VRAM use.
Up: Performance Tuning & Profiling for WebGPU Spatial Pipelines