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.

From the zoom pyramid to a resident working set, LRU eviction, and a fixed VRAM budget ceiling A left-to-right pipeline in four stages. Stage one is the tile zoom pyramid: zoom level 4 holds a handful of tiles, zoom 5 holds four times as many, zoom 6 four times more again, so tile count multiplies by four per level. Stage two is the resident working set: only the tiles inside the viewport plus a one-tile prefetch ring are made resident as GPUBuffers in VRAM; distant pyramid tiles are never uploaded. Stage three is the least-recently-used list: resident tiles are ordered from most-recently-used at the head to least-recently-used at the tail, and when the budget is exceeded the tail tile is destroyed and its buffer returned to a pool. Stage four is the budget ceiling: a vertical VRAM bar shows used bytes filling toward a hard ceiling, with a low watermark where eviction stops and a high watermark where eviction starts; staying between the watermarks keeps the footprint bounded and predictable across zoom changes. zoom pyramid ×4 tiles per level z4 z5 z6 — count balloons resident working set viewport + prefetch ring prefetch ring GPUBuffers in VRAM recency order evict the tail head — most recently used tile 5/16/11 tile 5/15/11 tile 5/14/10 tail — LRU → destroy() buffer returned to pool VRAM budget hard ceiling used ceiling high low WORKING-SET LOOP · MAKE RESIDENT ON DEMAND · EVICT LRU WHEN OVER HIGH WATERMARK · RECYCLE VIA POOL 1 · estimate per-tile bytes × tiles-in-view 2 · admit upload only visible + ring; touch = MRU 3 · evict over high → destroy tail until low 4 · recycle pool the freed buffer, no realloc Footprint stays bounded by the ceiling regardless of how far the camera pans or zooms.
The tile pyramid multiplies visible tiles per zoom level; a working-set budget with LRU eviction and buffer pooling holds VRAM under a fixed ceiling.

Prerequisites

Before wiring a budget into a tiled renderer, you should have:

  1. A valid GPUDevice whose limits you have already read — in particular maxBufferSize and maxStorageBufferBindingSize, which cap any single tile allocation. Adapter negotiation is covered in WebGPU device initialization for GIS workloads.
  2. A stable tile identity. Every tile needs a canonical key — the z/x/y triple 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.
  3. 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.
  4. 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.
  5. 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.

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

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

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

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

typescript
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, makes createBuffer throw a range error or the driver reject the allocation. Detection: the throw is synchronous at createBuffer, or residentBytes climbs past the ceiling in your own accounting. Fix: split oversized tiles into multiple buffers, and run evictToBudget on 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.lost resolves 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: a GPUValidationError naming a destroyed buffer, often one frame after a fast pan. Fix: defer destruction behind queue.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 createBuffer latency 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 writeBuffer volume. 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: derive tileFootprint from the actual aligned strides per memory alignment for spatial data buffers, and round every buffer up to the allocation granularity.

Continue in this section

Up: Performance Tuning & Profiling for WebGPU Spatial Pipelines