Sizing GeoServer JVM Heap and Thread Pools for WMS Load

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.

The Core Challenge: The Worst Case Is Set by the Client

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.

Image size is the term that squares A five-row grid of raw image buffer sizes at four bytes per pixel, with the total across sixteen concurrent requests. A 512 pixel tile costs a megabyte and sixteen concurrent cost sixteen megabytes; doubling the side quadruples the cost, so an 8192 pixel request needs 268 megabytes on its own and over four gigabytes at the same concurrency. Requested image Bytes per request At 16 concurrent 512x512 PNG 1.0 MB raw 16 MB 1024x1024 PNG 4.2 MB raw 67 MB 2048x2048 PNG 16.8 MB raw 268 MB 4096x4096 PNG 67.1 MB raw 1.07 GB 8192x8192 PNG 268 MB raw 4.3 GB

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.

Production-Ready Code

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

Step-by-Step Walkthrough

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.

Five settings, derived in this order A five-stage sizing order. The maximum image size is capped first so the worst case is bounded; the render thread pool is sized from a measured concurrency curve rather than a guess; the heap is derived from pool size times buffer size with headroom; the database connection pool is set at least as large as the render pool so it cannot become a hidden queue; and the whole thing is re-measured under load. Cap the image size reject before allocating Size the thread pool from the concurrency curve Size the heap pool x buffer x 3 Match the DB pool >= render threads Re-measure p95 under load gate measured arithmetic no hidden queue

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.

Verification

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.

Gotchas & Edge Cases

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.

A bigger heap makes a saturated renderer slower, not faster A decision diamond for throughput that fell after a heap increase. A larger live set makes each garbage collection pause longer, lengthening every request; a larger heap permitting more concurrent renders converts parallelism into contention; a heap larger than the container's memory limit causes swapping or an out-of-memory kill; and unchanged throughput means memory was never the constraint. Throughput fell after increasing the heap. What happened? Smaller heap, smaller pool collection time scales with live set Cap the pool more in flight is not more done Heap exceeds RAM the container limit was not set Not memory-bound look at the data store longer GC pauses more concurrency swapping unchanged

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.

Frequently Asked Questions

What is a sensible maximum image size?

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.

Should heap be larger on a machine with more RAM?

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.

Why does p95 latency get worse as I add threads?

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.

Does this apply to WFS and WCS requests too?

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