Cap the maximum requestable image size first, measure the concurrency at which throughput plateaus, then size the render pool at that number and the heap at roughly three times pool size times the largest permitted image buffer. Raising -Xmx without capping image size means the worst case is still unbounded, and raising it without capping the pool converts parallelism into garbage collection pauses.
A WMS client chooses WIDTH and HEIGHT. The server allocates a raster of that size — four bytes per pixel for an RGBA image, plus intermediate buffers during rendering and encoding. Nothing in the specification bounds the request, so without a cap the peak memory of the service is a function of what an arbitrary client asks for.
The squaring is what makes this dangerous. Moving from 512 to 4096 pixels a side is an eight-fold increase in dimension and a sixty-four-fold increase in memory. A single 8192-pixel request needs 268 megabytes of raw buffer before any of the intermediate copies, and sixteen of them concurrently exceed four gigabytes — which is more than the heap of most deployments. That is why the size cap comes before every other setting: until the worst case is bounded, no heap figure is safe.
from __future__ import annotations
from dataclasses import dataclass
BYTES_PER_PIXEL = 4 # RGBA
# Rendering holds the target raster plus intermediate buffers, and the
# collector needs headroom to work in. Three times the raw buffer is a
# conservative but reliable multiplier for a WMS workload.
BUFFER_MULTIPLIER = 3.0
# Below this fraction of free heap the collector runs continuously.
GC_HEADROOM = 0.35
@dataclass(frozen=True)
class Sizing:
max_pixels: int
render_threads: int
heap_mb: int
db_connections: int
def as_jvm_args(self) -> list[str]:
return [f"-Xms{self.heap_mb}m", f"-Xmx{self.heap_mb}m",
"-XX:+UseG1GC", "-XX:MaxGCPauseMillis=200",
"-XX:+ExitOnOutOfMemoryError"]
def __str__(self) -> str:
return (f"max_pixels={self.max_pixels:,} threads={self.render_threads} "
f"heap={self.heap_mb} MB db_pool={self.db_connections}")
def size_service(max_side: int, render_threads: int,
container_mb: int, bytes_per_pixel: int = BYTES_PER_PIXEL) -> Sizing:
"""Derive heap and pool sizes from a bounded worst case.
The worst case is `render_threads` requests each at `max_side` squared.
Anything the container cannot hold is a configuration error, not a
runtime surprise — so it raises here rather than at 3 a.m.
"""
buffer_bytes = max_side * max_side * bytes_per_pixel
working_set = buffer_bytes * render_threads * BUFFER_MULTIPLIER
heap_bytes = working_set / (1 - GC_HEADROOM)
heap_mb = int(heap_bytes / (1024 * 1024))
# The JVM needs non-heap memory too: metaspace, thread stacks, direct
# buffers and the native image codecs. A quarter of the container is a
# workable reservation for a GeoServer-sized process.
usable_mb = int(container_mb * 0.75)
if heap_mb > usable_mb:
raise ValueError(
f"a {max_side}px cap at {render_threads} threads needs {heap_mb} MB "
f"of heap but only {usable_mb} MB is usable in a {container_mb} MB "
f"container — lower the cap or the thread count")
return Sizing(max_pixels=max_side * max_side,
render_threads=render_threads,
heap_mb=heap_mb,
db_connections=render_threads + 2)
def recommend_from_curve(curve: dict[int, tuple[float, float]]) -> int:
"""Pick the thread count from a measured concurrency curve.
`curve` maps concurrency to (throughput_rps, p95_seconds). The right
setting is the highest concurrency where throughput is still rising
meaningfully — past that, extra threads add latency without work.
"""
best_concurrency, best_throughput = 1, 0.0
for concurrency in sorted(curve):
throughput, _ = curve[concurrency]
if throughput > best_throughput * 1.05: # still gaining >5%
best_concurrency, best_throughput = concurrency, throughput
else:
break
return best_concurrency
if __name__ == "__main__":
measured = {1: (11.2, 0.09), 2: (21.6, 0.10), 4: (39.8, 0.11),
8: (52.1, 0.17), 16: (54.0, 0.33), 32: (48.7, 0.71)}
threads = recommend_from_curve(measured)
print("chosen concurrency:", threads)
print(size_service(max_side=2048, render_threads=threads, container_mb=8192))
print(" ".join(size_service(2048, threads, 8192).as_jvm_args()))
chosen concurrency: 8
max_pixels=4,194,304 threads=8 heap=1476 MB db_pool=10
-Xms1476m -Xmx1476m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+ExitOnOutOfMemoryError
The cap comes first because it bounds everything else. Every other number is derived from max_side, and without it there is no worst case to size against. GeoServer’s own maximum rendering size setting, or a check at the request gate, both work; what matters is that a request above the cap is rejected before any allocation happens.
Thread count comes from measurement, not from cores. recommend_from_curve picks the highest concurrency at which throughput is still rising by more than five per cent. In the sample data that is eight: sixteen adds four per cent of throughput and doubles p95, and thirty-two loses throughput outright. Setting the pool to the core count is a guess that happens to be right sometimes.
-Xms equals -Xmx deliberately. A heap that grows on demand produces a service whose latency changes as it warms up, and in a container the growth can collide with the memory limit at exactly the wrong moment. Fixing both ends makes behaviour predictable and surfaces an over-large heap at start-up rather than under load.
The database pool must not be smaller than the render pool. Sizing it at render threads plus a small margin means no renderer ever waits for a connection. The margin covers the catalog’s own queries — the reload and cache operations also need connections.
Confirm the derived settings hold under the load they were sized for:
import requests
resp = requests.get(f"{base}/wms", params={
"SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetMap",
"LAYERS": "cadastre:parcels", "CRS": "EPSG:3857", "BBOX": bbox,
"WIDTH": "8192", "HEIGHT": "8192", "FORMAT": "image/png"})
assert "xml" in resp.headers["content-type"], "the size cap is not enforced"
print(resp.text[:200])
<ServiceExceptionReport><ServiceException code="InvalidParameterValue">
Requested map size exceeds the maximum of 4194304 pixels</ServiceException>
Then re-run the concurrency curve and confirm p95 stays inside budget at the chosen thread count, and that the heap does not creep across a sustained run — a heap that grows steadily under constant load is a leak, not a sizing problem.
The JVM does not see the container limit by default on older runtimes. A container capped at four gigabytes running a JVM that reads the host’s thirty-two can size its own structures for a machine it does not have, and be killed for exceeding its limit. Modern JVMs honour cgroup limits, but pinning -Xmx explicitly removes the question entirely.
Non-heap memory is not small. Metaspace, thread stacks, direct byte buffers and the native image codecs all live outside the heap, and for a GeoServer process they comfortably reach several hundred megabytes. Reserving a quarter of the container for them, as the code does, is a working rule of thumb rather than a precise figure.
MapServer has no heap to size. It forks a process per request or runs a fixed pool of FastCGI workers, so its memory scaling lever is the worker count multiplied by per-process footprint. The arithmetic is the same shape and the setting has a different name — one of the operational differences catalogued in GeoServer vs MapServer Feature Matrix.
-XX:+ExitOnOutOfMemoryError is worth setting. A JVM that has exhausted its heap rarely recovers into a useful state; it thrashes, serving slow requests and failing health checks ambiguously. Exiting lets the orchestrator replace it, which is both faster and easier to diagnose.
Whatever the largest legitimate client needs, and no more. For a web map viewer that is 2048 pixels a side at most; for a print-export service it may be larger, in which case that path deserves its own instance with its own sizing rather than raising the cap for everyone. The cap is what turns an unbounded worst case into an arithmetic one.
Only if the concurrency it supports is actually wanted. Heap size is derived from the working set you have chosen to allow — thread count times buffer size — not from what the machine can spare. Spare memory is better spent on the operating system’s file cache, which speeds up every read the data store performs.
Because past the saturation point the extra threads queue for the same constrained resource, and each one also adds to the live set the garbage collector has to traverse. Throughput plateaus while latency rises, which is the classic signature of over-provisioned concurrency and is exactly what the curve is for.
The shape does, with a different unit. A WFS response is bounded by feature count rather than pixel count, and a WCS response by sample count. Both need a cap for the same reason, and both should be counted into the same working set — a service sized only for its WMS traffic can still be taken down by one unbounded GetCoverage.
Back to Performance Tuning for OGC Service Backends
Related