Performance Tuning for OGC Service Backends

A slow OGC service is rarely slow for the reason people first assume. The instinct is to add heap or add workers, and both frequently make throughput worse — more concurrent renders means more simultaneous image buffers, which means more garbage collection, which means longer requests. This guide covers the four tiers where time is actually spent, how to tell the bottlenecks apart by their signatures, and what to change once you know which one you have.

Prerequisites & Architecture Context

This applies to any OGC service backed by GeoServer or MapServer over PostGIS. On the measurement side you need the service’s own request logging, EXPLAIN (ANALYZE, BUFFERS) on the database, and a load generator that can replay a realistic mix of requests rather than hammering one URL.

The single most important preparatory step is having a realistic request mix. Tile traffic and analytic traffic have opposite profiles: a tile workload is thousands of tiny cacheable requests where per-request overhead dominates, while a GetFeature workload is a handful of enormous responses where data transfer dominates. Tuning for one degrades the other, so measure the mix you actually serve before changing anything.

Four tiers, and the cheapest fix is always the one furthest up Four stacked tiers of an OGC service stack. An edge cache absorbs tiles and immutable responses before they reach the service; a request gate coalesces duplicates, rate limits clients and rejects oversized requests; the renderer is bounded by JVM heap and thread pool for GeoServer or by worker count for MapServer; and the data store is bounded by its connection pool and its spatial indexes. Edge cache tiles and immutable responses never reach the service Request gate coalescing, rate limits, size caps Renderer JVM heap and thread pool, or FastCGI workers Data store connection pool, spatial index, geometry simplification

The tiers matter because the cost of a fix rises sharply as you descend. Absorbing a request at the edge costs nothing per request. Rejecting it at the gate costs a few milliseconds. Rendering it costs tens to hundreds of milliseconds and a chunk of heap. Querying for it costs a database round trip and possibly a scan. Any request you can push up a tier is worth more than any optimisation within a tier.

Specification Deep-Dive: Which Requests Are Cacheable

Cacheability is decided by the shape of the request, not by policy. WMTS addresses a fixed grid, so its whole response space is enumerable and every response is immutable until the data changes — the ideal case, approaching a hundred per cent hit rate once seeded. WMS takes an arbitrary bounding box and image size, so the response space is effectively infinite and the hit rate is whatever fraction of clients happen to request identical views.

That asymmetry is the strongest architectural lever available. A viewer that requests WMS at arbitrary extents can often be changed to request WMTS at grid-aligned extents, which converts an uncacheable workload into a cacheable one without touching the server. Where the client cannot change, a tile-aligned proxy in front of the WMS achieves the same thing at the cost of a small amount of over-rendering at the edges.

GetFeature and OGC API - Features item requests sit in between. A URL-addressed items page with an ETag caches well on a collection that changes slowly; a POST body query does not cache at all. The pagination discipline matters here too — bounded pages are cacheable objects, whereas one enormous response is a single uncacheable transfer that also holds a connection for its whole duration.

Python Implementation: Measuring Before Changing

The decomposition below is what makes tuning empirical rather than superstitious. It measures the same request at three points — through the cache, through the service, and against the database directly — and the differences localise the cost.

from __future__ import annotations

import statistics
import time
from dataclasses import dataclass, field

import requests

@dataclass
class Sample:
    label: str
    durations: list[float] = field(default_factory=list)

    def add(self, seconds: float) -> None:
        self.durations.append(seconds)

    @property
    def p50(self) -> float:
        return statistics.median(self.durations) if self.durations else float("nan")

    @property
    def p95(self) -> float:
        if len(self.durations) < 20:
            return max(self.durations, default=float("nan"))
        return statistics.quantiles(self.durations, n=20)[18]

    def __str__(self) -> str:
        return (f"{self.label:<22} n={len(self.durations):<4} "
                f"p50={self.p50 * 1000:7.1f} ms  p95={self.p95 * 1000:7.1f} ms")

def time_request(session: requests.Session, url: str,
                 params: dict[str, str], sample: Sample,
                 warm: bool = True) -> requests.Response:
    """Time one request, optionally defeating every cache in the path."""
    headers = {} if warm else {"Cache-Control": "no-cache", "Pragma": "no-cache"}
    started = time.perf_counter()
    resp = session.get(url, params=params, headers=headers, timeout=120)
    resp.raise_for_status()
    resp.content                                  # force the full transfer
    sample.add(time.perf_counter() - started)
    return resp

def profile_getmap(base: str, layer: str, boxes: list[str],
                   repeats: int = 30) -> dict[str, Sample]:
    """Compare cold and warm timings for the same set of views.

    A large gap between cold and warm means caching is doing the work and
    the renderer is not the bottleneck. A small gap means every request is
    reaching the renderer, and the tiers below are where to look.
    """
    session = requests.Session()
    cold, warm = Sample("cold (no-cache)"), Sample("warm")

    for _ in range(repeats):
        for bbox in boxes:
            params = {
                "SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetMap",
                "LAYERS": layer, "STYLES": "", "CRS": "EPSG:3857",
                "BBOX": bbox, "WIDTH": "512", "HEIGHT": "512",
                "FORMAT": "image/png", "TRANSPARENT": "true",
            }
            time_request(session, f"{base}/wms", params, cold, warm=False)
            time_request(session, f"{base}/wms", params, warm, warm=True)

    return {"cold": cold, "warm": warm}

def concurrency_curve(base: str, layer: str, bbox: str,
                      levels: tuple[int, ...] = (1, 2, 4, 8, 16, 32)) -> None:
    """Throughput against concurrency — where saturation is, not whether.

    Latency that rises linearly with concurrency while throughput stays
    flat is queueing: a pool somewhere is the limit. Latency that rises
    while throughput falls is contention — usually garbage collection.
    """
    from concurrent.futures import ThreadPoolExecutor

    params = {"SERVICE": "WMS", "VERSION": "1.3.0", "REQUEST": "GetMap",
              "LAYERS": layer, "STYLES": "", "CRS": "EPSG:3857", "BBOX": bbox,
              "WIDTH": "512", "HEIGHT": "512", "FORMAT": "image/png"}

    for workers in levels:
        session = requests.Session()
        session.mount("https://", requests.adapters.HTTPAdapter(
            pool_connections=workers, pool_maxsize=workers))
        sample = Sample(f"concurrency={workers}")
        started = time.perf_counter()
        with ThreadPoolExecutor(max_workers=workers) as pool:
            list(pool.map(
                lambda _: time_request(session, f"{base}/wms", params, sample),
                range(workers * 10)))
        elapsed = time.perf_counter() - started
        print(f"{sample}  throughput={workers * 10 / elapsed:6.1f} req/s")

Run the concurrency curve before changing a single setting. It answers the only question that matters first: whether the service is saturated at all, and if so whether it is queueing or thrashing. Those two have opposite fixes.

Five places the time in a GetMap goes A single slow GetMap request in the centre, decomposed into the five places its time is spent: waiting for a free worker, fetching data from the store, reprojecting geometry vertex by vertex, rendering rules against features, and encoding and transferring the image. Optimising without knowing which dominates is guesswork. One slow GetMap where the time actually went Queue wait all workers busy Data fetch query plus transfer Reprojection per-vertex transform Rendering rules times features Encode + transfer PNG compression, bytes out

Error Handling & Edge Cases

More heap is not more throughput. A larger JVM heap lets more requests be in flight simultaneously, each holding an image buffer. Past a certain point the additional concurrency produces longer garbage collection pauses that lengthen every request, and throughput falls while memory usage looks healthy. The heap sizing guide works through the arithmetic.

Five bottlenecks with distinguishable signatures A five-row grid of spatial service bottlenecks. Heap exhaustion shows as out-of-memory errors and long garbage collection pauses; thread starvation shows as queueing with idle processors; connection pool waits show only under concurrency; a missing spatial index shows as one layer being slow always; and a cold cache is fast on the second request. Each has a different first measurement. Bottleneck Symptom First thing to measure Heap exhaustion OOM, long GC pauses concurrent request count x image size Thread starvation queueing, no CPU load pool size vs in-flight requests Connection wait slow under load only pool size vs concurrent renders Sequential scan one layer slow always EXPLAIN on the bbox query Cold cache slow first, fast after cache hit ratio

A connection pool smaller than the render pool is a hidden queue. If sixteen renderer threads share eight database connections, half the renderers wait for a connection while showing no CPU load. The symptom is latency that rises under concurrency with idle processors, which is easily mistaken for a network problem.

One missing spatial index dominates everything else. A bounding-box query without a GiST index is a sequential scan whose cost grows with the table, and no amount of tuning elsewhere compensates. This is why the very first measurement on a slow layer should be EXPLAIN (ANALYZE, BUFFERS) on the query the renderer issues — a detail the PostGIS sync guide covers from the publishing side.

Reprojection is per vertex. Serving a layer in a reference system other than the one it is stored in transforms every vertex of every returned geometry on every request. For a dense polygon layer that is a meaningful fraction of the request budget, and the fix is either to store in the system most requested or to accept the cost knowingly — see Building a CRS Whitelist.

Testing & Compliance Verification

Performance work belongs in the same pipeline as correctness work, and for the same reason: a regression nobody measures ships. The practical form is a small set of representative requests with a latency budget attached, asserted on every build against a disposable instance — the pattern described in CI/CD and Compliance Testing for Spatial Services.

BUDGETS_MS = {
    ("basemap:hillshade", "tile"): 120,
    ("cadastre:parcels", "detail"): 400,
    ("cadastre:parcels", "overview"): 900,
}

def test_render_budgets(service_url):
    failures = []
    for (layer, kind), budget in BUDGETS_MS.items():
        sample = Sample(f"{layer}/{kind}")
        for _ in range(20):
            time_request(requests.Session(), f"{service_url}/wms",
                         REQUESTS[kind] | {"LAYERS": layer}, sample)
        if sample.p95 * 1000 > budget:
            failures.append(f"{layer}/{kind}: p95 {sample.p95 * 1000:.0f} ms "
                            f"exceeds {budget} ms")
    assert not failures, "\n".join(failures)

Assert on the ninety-fifth percentile rather than the mean. A mean hides exactly the tail that users notice, and a service whose mean is fine and whose p95 is four times the budget is a service with an intermittent bottleneck — usually garbage collection or connection waiting.

Performance & Scaling Notes

Scale the tier that is saturated, not the whole stack. A saturated renderer scales horizontally: more instances behind a load balancer, sharing a tile cache. A saturated database does not — adding renderers makes it worse. Establishing which is saturated is the entire value of the concurrency curve.

Seed the low zoom levels and nothing else. Every session requests the coarse levels and only some sessions reach the fine ones, so seeding zoom zero to eight globally costs hours and removes most of the load, while seeding to fourteen costs months. The arithmetic is in Pre-Seeding a WMTS Tile Cache.

Simplify geometry per zoom level. Serving full-resolution coastline at country zoom sends millions of vertices to draw a few hundred pixels. Pre-generalised geometry columns selected by scale — or PostGIS ST_Simplify inside a SQL view — reduce both the transfer and the render cost by an order of magnitude at coarse scales.

Cap what a single request may cost. A GetMap at 8000 by 8000 pixels, or a GetCoverage over a whole continent, can consume more memory than the rest of the workload combined. A size cap at the gate turns a service-wide outage into one rejected request, which is the trade the rate limiting guide makes explicit.

Measure again after every change. Tuning is a loop, and the second measurement frequently shows that the fix moved the bottleneck rather than removing it. A change that improves p95 by a factor of three and moves the constraint from the renderer to the database is a success — provided you notice.

Frequently Asked Questions

Should I increase the JVM heap when GeoServer is slow?

Only after establishing that memory is the constraint, which is rarer than assumed. A larger heap permits more concurrent renders, each holding an image buffer, and past a threshold the extra concurrency costs more in garbage collection pauses than it gains in parallelism. Latency that rises while throughput falls as concurrency increases is the signature of that, and the fix is usually a smaller thread pool rather than a larger heap.

How many database connections should the pool have?

At least as many as the renderer can have requests in flight, or the pool becomes an invisible queue. If GeoServer is configured for sixteen concurrent renders and the store has eight connections, half the renderers wait with no CPU load and the symptom looks like network latency. Match them, then size both from the concurrency curve.

Is a CDN worth it in front of a WMS?

For WMTS, unequivocally — the response space is a fixed grid and every response is immutable, so hit rates approach one hundred per cent. For WMS with arbitrary bounding boxes, a CDN caches almost nothing, because no two clients ask for exactly the same view. The valuable move there is converting the workload to tile-aligned requests, after which the CDN becomes worth it.

What single measurement is most useful?

The concurrency curve: latency and throughput at increasing parallelism. It distinguishes the two fundamentally different failure modes — queueing, where latency rises linearly and throughput plateaus, and contention, where latency rises while throughput falls — and those have opposite remedies. Almost every other measurement only makes sense once you know which of the two you have.

How do I stop one client degrading the service for everyone?

Cap the cost of a single request and rate limit per client at the gate, before the renderer is involved. A pixel-count cap on GetMap and GetCoverage bounds the worst case, and a token bucket per client bounds the aggregate. Both are cheap because they run before any data is touched.


Back to Python Automation for GeoServer & MapServer

Related