Evicting Tile Textures Without Stalling the Queue

The sub-problem is narrow and sharp: a tile cache decides that layer 17 of a texture_2d_array is no longer worth keeping, and the GPU may still be sampling it for a frame that was submitted a moment ago. Calling texture.destroy() there is a use-after-free that the validator rejects; waiting for the GPU to finish before evicting stalls the pipeline. The answer is to stop destroying textures at all and to recycle layers under a submission counter, which makes eviction a bookkeeping operation with no synchronisation in it. This page is that mechanism in full. It is one stage of texture and tile atlas management in WebGPU, and it composes with the byte accounting in building an LRU VRAM cache for tile buffers.

The counter that makes eviction safe Three steps. Every submission increments a monotonic counter, and a promise resolving when that submission retires records the highest retired number. While recording a frame, every layer the frame reads records the number of the submission being recorded. Allocation then only hands out a free layer whose recorded number is at or below the highest retired number, which means no layer is ever reused while the GPU may still be reading it and nothing in the frame path ever waits. SUBMISSION COUNTER · THREE STEPS 1 Number every submission onSubmittedWorkDone raises "retired" nothing awaits it inline 2 Stamp every layer read lastUsed[layer] = submission + 1 recorded while encoding 3 Only reuse retired layers lastUsed[layer] <= retired otherwise skip it this frame Returning null from acquire is normal behaviour, not an error path.
The design is entirely non-blocking. A layer that is not yet safe is simply not chosen, and the tile it would have held renders from the coarser level until a later frame can take it.

Runnable reference implementation

The whole design rests on one monotonically increasing counter. Every submission gets a number; every layer records the number of the last submission that referenced it; a layer is reusable once the queue has retired that submission.

typescript
class LayerPool {
  private readonly free: number[] = [];
  private readonly lastUsed = new Map<number, number>();   // layer -> submission
  private submission = 0;
  private retired = 0;

  constructor(private readonly layerCount: number) {
    for (let i = layerCount - 1; i >= 0; i--) this.free.push(i);
  }

  /** Called once per frame, after the encoder is submitted. */
  markSubmitted(device: GPUDevice): void {
    const id = ++this.submission;
    // Resolves when this submission has retired; nothing awaits it inline.
    void device.queue.onSubmittedWorkDone().then(() => {
      if (id > this.retired) this.retired = id;
    });
  }

  /** Called while recording, for every layer the frame reads. */
  touch(layer: number): void {
    this.lastUsed.set(layer, this.submission + 1);   // the frame being recorded
  }

  /** Eviction: the layer goes on the free list, nothing is destroyed. */
  release(layer: number): void {
    this.free.push(layer);
  }

  /** Allocation: only hand out a layer whose last reader has retired. */
  acquire(): number | null {
    for (let i = this.free.length - 1; i >= 0; i--) {
      const layer = this.free[i];
      if ((this.lastUsed.get(layer) ?? 0) <= this.retired) {
        this.free.splice(i, 1);
        return layer;
      }
    }
    return null;      // everything free is still in flight — try next frame
  }
}

The acquire returning null is the important behaviour, and it is deliberately not an error. A frame that cannot get a layer simply does not upload that tile this frame; the tile renders from the coarser zoom level below it, which is what the pyramid is for, and the allocation succeeds a frame or two later once the in-flight submissions retire. No code path ever waits.

The counter is also why nothing is destroyed. texture.destroy() is called exactly once per array, when a whole zoom level is retired — a deliberate, rare event that can afford to await onSubmittedWorkDone() because it is not on the frame path.

Parameter reference

Value Default here Guidance for tile workloads
Layers per array 64 Covers a 1440-pixel viewport at 512-pixel tiles with room to pan. Above 256 is not guaranteed by the spec.
In-flight submissions 2–3 The counter’s gap between submission and retired. Larger gaps mean more layers are unavailable at any moment.
Free-list search from the tail Most-recently-released first, so a layer released three frames ago is the last candidate — it is the one most likely to still be wanted.
Reserve layers 4 Held back from the cache so a viewport change always has somewhere to put its first tiles.
destroy() calls per session one per array Only when a zoom level is retired entirely.

The reserve is worth the four layers it costs. Without it, a fast zoom can find every layer in flight, upload nothing for several frames, and show a visibly blank map during exactly the interaction where the user is paying attention.

One texture per tile against one array of layers A comparison of two designs across four properties. One texture per tile needs a deferred-destroy queue, one bind group per tile, an allocation and free per tile lifetime, and supports variable tile sizes. One array of recycled layers needs no destroy on the frame path, one bind group for the whole viewport, no allocation after start-up, and requires every tile to share dimensions. TEXTURE-PER-TILE vs LAYER POOL Texture per tile Layer pool Destroy hazard deferred queue needed none on the frame path Bind groups one per tile one per viewport Allocation churn per tile lifetime none after start-up Tile sizes may vary must match Both designs need the same submission tracking — the pool just uses it for reuse rather than for destruction.
Only the last row favours the naive design, and for a basemap of fixed-size tiles it costs nothing. The other three are the reasons the pool is worth the counter it needs.

Why recycling beats destroying

There is a simpler-looking design where each tile owns its own single-layer texture and eviction destroys it. It is worth understanding why that design loses, because it is the one most people write first.

The first cost is the destroy hazard itself. A texture destroyed while a submitted command buffer still references it is invalid, so the simple design needs a deferred-destroy queue, which is the same counter this page describes plus an extra object lifetime to track. The complexity does not go away; it moves.

The second is binding. One texture per tile means one bind group per tile and a setBindGroup call per tile in the render pass — sixty state changes to draw sixty tiles. The array design binds once and selects layers with an integer that travels in the instance data, so a whole viewport of tiles is one bind group and one instanced draw.

The third is allocation churn. Creating and destroying a megabyte texture per tile, several times a second during a pan, is exactly the pattern that fragments a driver’s memory allocator. Layers in a pre-allocated array cannot fragment, because nothing is ever allocated after start-up.

The one thing recycling gives up is variable tile sizes, since every layer of an array shares dimensions. For a basemap of fixed-size tiles that is no loss at all.

Sizing the pool

Three numbers decide how large the layer array should be, and getting any of them wrong shows up as either wasted memory or a map that will not fill in.

The first is the visible set: how many tiles cover the viewport at the current zoom. For a 1440-pixel-wide window at 512-pixel tiles that is about four across and three down, so twelve — and a little more once the viewport is not aligned to the tile grid, which it never is. Sixteen is the honest number for that window.

The second is the pan margin. A tile that comes into view during a pan and has to be fetched, decoded and uploaded before it can be drawn will appear a beat late; keeping one ring of tiles outside the viewport resident removes that entirely. One ring around a four-by-three visible set is another eighteen tiles.

The third is the in-flight band — the layers that have been released but whose last reader has not retired. With two submissions in flight and a pan releasing a few tiles per frame, that is typically four to eight layers unavailable at any moment.

Sixteen plus eighteen plus eight is forty-two, and rounding up to sixty-four leaves headroom for a window larger than the one the numbers were derived from. That is the reasoning behind the default at the top of this page, and it is worth redoing rather than inheriting when the tile size or the target window changes.

Failure modes

  • Destroyed texture used in a submit. A texture destroyed while an in-flight submission referenced it. Fix: do not destroy on the frame path; recycle layers instead.
  • A recycled layer shows the previous tile when zoomed out. The upload overwrote mip level zero and left the older levels intact. The mip chain has to be rebuilt as part of the layer hand-off, not as part of the fetch.
  • The map goes blank during a fast zoom. Every free layer is still in flight and acquire keeps returning null. Fix: hold back a small reserve, and confirm the in-flight submission count is bounded at two or three.
  • Layers leak until nothing is free. release is called on some eviction paths and not others — typically the path that fires when a fetch fails. Fix: release in a finally, and assert that free plus allocated equals the layer count once per second in development.
  • A tile flickers between two images. Two cache entries were handed the same layer, because acquire was called twice without the first result being recorded. Fix: make the pool the only allocator and never track layer numbers elsewhere.
A pool of 24 layers midway through a pan A grid of twenty-four layer slots showing pool state during a pan. Fourteen layers hold tiles currently visible and are in use. Four have been released by the cache but are still referenced by an in-flight submission, so they cannot yet be reused. Two are free and safe to allocate immediately. The remaining four are held back as a reserve so a sudden viewport change always has somewhere to put its first tiles. LAYER POOL · 24 SLOTS 14 in use 4 released but in flight 2 free right now 4 held in reserve in use released, still in flight free now reserve Sizing the pool means sizing the amber band as well as the visible set.
The amber band is the interesting one: those layers are logically evicted and physically unavailable, and the gap between the two states is exactly the number of submissions in flight.

Backend / Python interop note

Eviction is a client-side concern, but the tile server decides how expensive a mistake is, and one server-side property matters more than any other: cache headers that let the browser re-serve an evicted tile without a network round trip.

A tile evicted from VRAM will very often be wanted again within seconds — a pan that goes out and comes back, a zoom that overshoots. If the response carried a long Cache-Control: max-age and an ETag, the refetch is served from the HTTP cache in a millisecond and the only real cost is the decode and upload. Without them, every eviction is a potential network request, and an aggressive VRAM budget becomes a bandwidth problem instead of a memory one.

The practical guidance for a Python tile service is to treat tile URLs as immutable — put a version or a data timestamp in the path rather than in a query parameter — and to serve them with a year-long max-age. That combination lets the browser cache hold what VRAM cannot, and it turns the eviction policy into a pure memory decision rather than a memory-versus-latency trade.