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.
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.
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
OperationErrorfromrequestDevice()— limit over-request. Cause: arequiredLimitsvalue exceedsadapter.limits(commonly assuming 4 GiBmaxBufferSizeor 512 MiB binding on integrated hardware). Detection: thetry/catcharoundrequestDevicefires 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.GPUValidationErroronsetBindGroup()— binding exceeds negotiated range. Cause: a coordinate buffer larger thandevice.limits.maxStorageBufferBindingSizeis bound whole because chunking was keyed off the requested rather than the negotiated limit. Detection: wrap the bind/dispatch block indevice.pushErrorScope("validation")andawait device.popErrorScope(). Fix: chunk againstdevice.limits.maxStorageBufferBindingSizeand 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:
chunkRowsis not a multiple of the workgroup size, so the last dispatch covers a partial group and reads past the slice, or a non-4-bytesizeis rejected. Detection: incorrect vertices only at the tail of each chunk; or a validation error oncreateBuffer. Fix: roundchunkRowsdown to a workgroup multiple (the reference does this) and keepsizea 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:
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.
Related
- WebGPU Compute vs Render Pipeline Fundamentals — the pipeline boundary the negotiated limits feed.
- Memory Alignment for Spatial Data Buffers — the stride and 16-byte rules the chunk layout must satisfy.
- Setting Up WebGPU Device Polling for GIS Apps — the bounded acquisition loop that produces the device this routine negotiates.
- Browser Support & Fallback Routing Strategies — where a rejected device or absent adapter reroutes the session.
- WebGPU Architecture for Spatial Visualization — the architectural overview these limits sit within.