How to Configure WebGPU Adapter Limits for Large GeoJSON

The precise sub-problem here is negotiating requiredLimits at device creation so a GeoJSON payload larger than WebGPU’s guaranteed defaults can actually be bound to the GPU. The default maxStorageBufferBindingSize a conformant implementation must support is only 128 MiB, and the default maxBufferSize is 256 MiB — a single municipal boundary file, a continental vector-tile set, or a LiDAR-derived point cloud flattened to Float32Array blows past both. Request nothing and createBuffer() or setBindGroup() rejects the oversized coordinate buffer; request blindly and requestDevice() rejects because the number exceeds what the physical adapter advertises. The correct path is to read the adapter’s real ceilings, clamp your request to them, validate the negotiated device against the dataset’s byte size, and chunk the coordinate buffer when even the maximum binding cannot hold it in one piece. This page sits underneath the compute versus render pipeline boundary: the limits negotiated here govern how much spatial data a single compute pass can address before the transformed buffer is handed to a draw call.

Decision flow for negotiating WebGPU adapter limits against a large GeoJSON payload A top-to-bottom flowchart. requestAdapter() leads to reading the adapter's real limit ceilings (whose spec floor is 128 MiB binding and 256 MiB buffer), then clamping every requiredLimit to those ceilings, then requestDevice(requiredLimits). If the device is rejected with an OperationError the flow branches right to the fallback route; if resolved it continues down to a size check comparing datasetBytes against the negotiated maxStorageBufferBindingSize. If the dataset fits, a single storage buffer is bound and dispatched once; if it exceeds the bindable range, the flow branches right to chunk the coordinate array into N workgroup-aligned segments and dispatch one compute pass per chunk. The chunk-size formula is shown at the bottom. rejected resolved exceeds fits requestAdapter() read adapter.limits real hardware ceilings spec-guaranteed floor 128 MiB binding · 256 MiB buffer clamp each requiredLimit min(requested, adapter ceiling) requestDevice(requiredLimits) resolved device ? OperationError ? FALLBACK route limit over-request — no retry datasetBytes ≤ negotiated maxStorageBufferBindingSize ? check device.limits, not the request chunk into N = ⌈bytes ÷ bind⌉ one storage buffer per segment dispatch one compute pass each single storage buffer bind whole · one dispatch Per-chunk size formula chunkRows = ⌊ ⌊ maxBindBytes ÷ strideBytes ⌋ ÷ WG ⌋ × WG Keeps every chunk under the bindable range and an exact multiple of the workgroup size (WG).

Runnable reference implementation

The routine below queries the adapter, clamps every requested limit to the hardware ceiling, creates the device, and then sizes the GeoJSON coordinate upload — splitting it into bindable chunks when the negotiated maxStorageBufferBindingSize is smaller than the dataset. It is written in TypeScript so the descriptor shapes stay checked against @webgpu/types; the spatial-data-specific choices are called out inline.

typescript
interface GeoGpuContext {
  device: GPUDevice;
  adapter: GPUAdapter;
  maxBindBytes: number;   // largest storage range one bind group can address
  chunkRows: number;      // vertices per chunk, derived from the negotiated limit
}

// Clamp a desired limit to what the adapter actually advertises. Requesting
// above adapter.limits makes requestDevice() reject outright — never degrade.
function clamp(desired: number, ceiling: number): number {
  return Math.min(desired, ceiling);
}

async function initGeoContext(datasetBytes: number, strideBytes = 8): Promise<GeoGpuContext | null> {
  if (!navigator.gpu) return null; // API absent — route to the fallback path.

  // high-performance biases adapter selection toward the discrete GPU, which
  // carries the storage-buffer bandwidth dense vector tiles and point clouds need.
  const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
  if (!adapter) return null;

  // Ask for as much as the dataset wants, but never above the adapter ceiling.
  // 256MiB is a pragmatic cap: large enough for a tile, small enough that a
  // device loss does not evict the entire scene at once.
  const requiredLimits: Record<string, number> = {
    maxStorageBufferBindingSize: clamp(256 * 1024 * 1024, adapter.limits.maxStorageBufferBindingSize),
    maxBufferSize: clamp(256 * 1024 * 1024, adapter.limits.maxBufferSize),
    maxComputeWorkgroupSizeX: clamp(256, adapter.limits.maxComputeWorkgroupSizeX),
    maxComputeInvocationsPerWorkgroup: clamp(256, adapter.limits.maxComputeInvocationsPerWorkgroup),
    maxStorageBuffersPerShaderStage: clamp(8, adapter.limits.maxStorageBuffersPerShaderStage),
  };

  let device: GPUDevice;
  try {
    device = await adapter.requestDevice({ requiredLimits });
  } catch (err) {
    console.error("requestDevice rejected:", (err as Error).message);
    return null; // a rejected device is a routing decision, not a retry.
  }

  // Validate the *negotiated* ceiling, not the requested one: the device may
  // have been granted less than asked on constrained or shared hardware.
  const maxBindBytes = device.limits.maxStorageBufferBindingSize;
  if (datasetBytes > maxBindBytes) {
    console.warn(
      `GeoJSON (${datasetBytes} B) exceeds bindable range (${maxBindBytes} B) — chunking.`,
    );
  }

  // chunkRows must keep each chunk under maxBindBytes AND be a multiple of the
  // workgroup size so the final dispatch has no half-empty trailing group.
  const WG = requiredLimits.maxComputeWorkgroupSizeX;
  const rowsPerBind = Math.floor(maxBindBytes / strideBytes);
  const chunkRows = Math.floor(rowsPerBind / WG) * WG;

  device.lost.then((info) => {
    console.warn(`WebGPU device lost (${info.reason}): ${info.message}`);
  });

  return { device, adapter, maxBindBytes, chunkRows };
}

// Upload an interleaved coordinate array as one or more storage buffers, each
// sized to the negotiated binding limit, and dispatch a compute pass per chunk.
function uploadGeoChunks(
  ctx: GeoGpuContext,
  coords: Float32Array,   // [x0, y0, x1, y1, ...] pre-flattened on the backend
  componentsPerVertex = 2,
): GPUBuffer[] {
  const { device, chunkRows } = ctx;
  const elemsPerChunk = chunkRows * componentsPerVertex;
  const buffers: GPUBuffer[] = [];

  for (let offset = 0; offset < coords.length; offset += elemsPerChunk) {
    const slice = coords.subarray(offset, offset + elemsPerChunk);
    // size must be a multiple of 4 (the GPUBuffer alignment minimum); a
    // Float32 slice is inherently 4-byte aligned, so byteLength is already valid.
    const buffer = device.createBuffer({
      size: slice.byteLength,
      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
      label: `geo-chunk@${offset}`,
    });
    device.queue.writeBuffer(buffer, 0, slice);
    buffers.push(buffer);
  }
  return buffers;
}

The load-bearing detail is the two-stage clamp: clamp() keeps the request legal against adapter.limits, and the datasetBytes > maxBindBytes check validates the result against the data. Both matter, because a device can be granted less than requested on integrated or policy-restricted hardware, and a chunking strategy keyed off the requested number rather than the negotiated one will allocate buffers the device still refuses to bind.

Parameter and configuration reference

Every tunable value in the routine above, with guidance for large vector workloads:

Parameter Default here Spatial-workload guidance
maxStorageBufferBindingSize request 256 MiB The spec-guaranteed floor is only 128 MiB; raise toward the adapter ceiling for whole-tile binding, but keep per-binding size modest so one device loss does not evict the entire scene.
maxBufferSize request 256 MiB Hardware tops out near 2 GiB (2^31) on most current GPUs, never the 4 GiB a naive default assumes. Bound it to the largest single chunk you will allocate.
maxComputeWorkgroupSizeX 256 256 invocations balance occupancy against register pressure for per-vertex transform/cull kernels; multiples of the wavefront (32 NVIDIA / 64 AMD) avoid wasted lanes.
maxComputeInvocationsPerWorkgroup 256 Must be ≥ the product of your workgroup dimensions; over-requesting forces a device rejection on low-end parts.
maxStorageBuffersPerShaderStage 8 One stage rarely needs more than coords + attributes + index + output; 8 leaves headroom without inflating the descriptor.
strideBytes 8 Bytes per vertex — 8 for interleaved vec2<f32> lon/lat, 16 if you carry z plus a padding lane for vec4<f32> alignment. Drives chunkRows.
chunkRows derived floor(floor(maxBindBytes / strideBytes) / WG) * WG — keeps every chunk under the bindable range and an exact multiple of the workgroup size.
powerPreference "high-performance" Prefers the discrete adapter for dense geometry; use "low-power" only for static low-zoom overviews on laptops.

The strideBytes you pick is not a private frontend choice — it is the byte layout the data layer must serialize against, governed by the memory alignment rules for spatial buffers. The negotiation itself assumes a device handle is already in hand; the bounded acquisition loop that produces one is covered in device polling, and the broader handshake in initializing WebGPU devices for GIS workloads.

Failure modes specific to large-GeoJSON limit negotiation

  • OperationError from requestDevice() — limit over-request. Cause: a requiredLimits value exceeds adapter.limits (commonly assuming 4 GiB maxBufferSize or 512 MiB binding on integrated hardware). Detection: the try/catch around requestDevice fires before any buffer is touched. Fix: clamp every requested limit to the adapter ceiling first, as the reference does, and log the requested-versus-granted pair so over-requests surface in telemetry rather than as a blank map.
  • GPUValidationError on setBindGroup() — binding exceeds negotiated range. Cause: a coordinate buffer larger than device.limits.maxStorageBufferBindingSize is bound whole because chunking was keyed off the requested rather than the negotiated limit. Detection: wrap the bind/dispatch block in device.pushErrorScope("validation") and await device.popErrorScope(). Fix: chunk against device.limits.maxStorageBufferBindingSize and dispatch one compute pass per chunk.
  • Out-of-memory on createBuffer() — VRAM exhausted by total allocation. Cause: every chunk fits the per-binding limit, but the sum of in-flight chunk buffers plus output buffers exceeds VRAM. Detection: device.pushErrorScope("out-of-memory") returns a non-null error after allocation. Fix: stream chunks through a small pool of reused buffers instead of allocating all chunks at once; release transformed chunks before uploading the next zoom level.
  • Misaligned chunk size — silent garbage at chunk boundaries. Cause: chunkRows is not a multiple of the workgroup size, so the last dispatch covers a partial group and reads past the slice, or a non-4-byte size is rejected. Detection: incorrect vertices only at the tail of each chunk; or a validation error on createBuffer. Fix: round chunkRows down to a workgroup multiple (the reference does this) and keep size a multiple of 4 bytes.

Backend / Python interop note

The negotiated maxStorageBufferBindingSize is the chunk boundary the Python data layer must respect. A backend that serializes a continental coordinate array as one JSON blob forces the frontend to parse and re-flatten on the main thread and then discover the result will not bind. Pre-flatten server-side to interleaved float32, mirror the frontend’s binding ceiling exactly, and emit pre-chunked binary the frontend maps straight to storage buffers:

python
import numpy as np
import pyarrow as pa

MAX_STORAGE_BINDING = 256 * 1024 * 1024  # mirror the frontend clamp exactly
WORKGROUP = 256                          # mirror maxComputeWorkgroupSizeX

def chunk_geojson_coords(xy: np.ndarray) -> tuple[list[pa.Buffer], int]:
    """Split an (N, 2) lon/lat array into GPU-bindable, workgroup-aligned chunks."""
    xy = np.ascontiguousarray(xy, dtype=np.float32)   # interleaved x,y,x,y...
    stride = xy.dtype.itemsize * xy.shape[1]          # 8 bytes per vertex
    rows_per_bind = MAX_STORAGE_BINDING // stride
    chunk_rows = (rows_per_bind // WORKGROUP) * WORKGROUP  # align to workgroup
    chunks = [
        pa.py_buffer(xy[i : i + chunk_rows].tobytes())
        for i in range(0, len(xy), chunk_rows)
    ]
    return chunks, chunk_rows

Carry stride and chunk_rows in the response (a GeoParquet metadata field or an Arrow schema field) so the frontend allocates each storage buffer at exactly the negotiated size, with no runtime realignment. Keep RFC 7946 lon/lat ordering inside each interleaved pair while stripping the nested JSON envelope entirely — the GPU only ever sees the flat coordinate stream.

Up: WebGPU Compute vs Render Pipeline Fundamentals