Fixing a Spatial API That Is Slow Under Load

On this page

Problem statement

The endpoint responds in 40 ms when tested by hand and times out when ten people use it. That gap is not mysterious once the numbers are measured, and for spatial APIs the cause is almost always one of four things.

Measured on a single-worker FastAPI service serving Natural Earth's 4,596 provinces to ten concurrent clients (Example 4):

endpoint                          payload      throughput   p50 latency
50-feature page, serialised       1.01 MB         23.1 rps     422.1 ms
1,000-feature page, serialised   15.59 MB          2.6 rps   3,465.5 ms
50 ms blocking call, async def    1.01 MB         19.8 rps     504.4 ms
the same 50 ms, awaited           1.01 MB        175.6 rps      54.1 ms
precomputed body + ETag           1.01 MB        586.4 rps      16.3 ms
same, answering 304                    0 B      1,318.3 rps       5.3 ms

Four separate fixes, worth between 2.2 and 25 times each, and none of them requires a bigger machine.

Quick answer

Work down this list; each step is cheaper than the one after it:

# 1. is a blocking call inside an async endpoint?   8.9× in measurement
async def handler(): time.sleep(0.05)          # blocks the event loop
async def handler(): await anyio.sleep(0.05)   # does not

# 2. is the response too large?                     8.9× between page sizes
limit: int = Query(50, ge=1, le=200)           # not le=10000

# 3. is anything cached?          25× for a stored body, 2.2× more for a 304
headers = {"ETag": etag, "Cache-Control": "public, max-age=300"}

# 4. only then, add workers
uvicorn service:app --workers 8

Adding workers first is the common instinct and the least effective. One worker served 2.6 requests a second of the 1,000-feature page and 23.1 of the 50-feature one; eight workers on the large page would, at perfectly linear scaling, still manage fewer than one worker on the small page.

The measured throughput gain from fixing each of four causes of a slow spatial API. throughput gained by each fix, one worker, ten concurrent clientsawait, don’t block19.8 → 175.6 rps50 features, not 1,0002.6 → 23.1 rpsstore the body23.1 → 586.4 rpsanswer 304586.4 → 1,318 rps010×20×30×None of these needs a bigger machine, and adding workers fixes none of them spatialworkflow.io
Eight workers each serialising 12.4 MB are eight times the problem.

Blocking calls, payload size, caching, then workers

1. Find the blocking call in the async path

An async def endpoint runs on the event loop. Any synchronous call inside it — a database driver, requests, time.sleep, a heavy to_json() — blocks every other request in that worker for its duration.

Measured with a 50 ms synchronous call inside async def: 19.8 requests per second at 504 ms — one request every 50 ms, however many clients are waiting. The same 50 ms awaited: 175.6 requests per second at 54 ms. A factor of 8.9, from one call.

Two fixes:

# use an async library
async with httpx.AsyncClient() as client:
    response = await client.get(url)

# or move the sync work off the loop
from starlette.concurrency import run_in_threadpool
result = await run_in_threadpool(expensive_sync_function, argument)

A plain def endpoint in FastAPI is already run in a thread pool, so the simplest fix for a synchronous handler is to remove the async.

2. Measure the payload, because it is usually the answer

The measured throughput difference between a 1.01 MB page and a 15.59 MB page was 23.1 against 2.6 requests per second — 8.9 times, on the same server, for the same endpoint.

That is not a server problem. Serialising and transmitting 15 times as much data took nearly nine times as long per request. Reduce it:

  • Cap the page size. 50 features, not 1,000.
  • Round the coordinates. Six decimal places took the whole layer from 68.35 MB of GeoJSON to 46.44 MB — a 32% cut.
  • Send only the properties the client uses.
  • Enable gzip. The 50-feature page's 1,010,943 bytes became 337,521, a factor of 3.00.

3. Cache, because the cheapest request is one that does no work

Measured: serialising the 50-feature page on every request served 23.1 requests per second; storing the body once served 586.4 per second at 16.3 ms, and the conditional request answered with 304 served 1,318.3 per second at 5.3 ms.

Twenty-five times for computing the body once, and another 2.2 times for an ETag header. And a response inside its max-age window costs the server nothing at all, because the client does not send a request.

Derive the ETag from a data version plus the query parameters, so the 304 path never touches the data.

4. Profile the handler before optimising the query

The instinct is to blame the database. Time the parts:

import time
from contextlib import contextmanager


@contextmanager
def timed(label, into):
    start = time.perf_counter()
    yield
    into[label] = time.perf_counter() - start


x0, y0, x1, y1 = -10, 35, 30, 60                       # a bounding box over Europe
layer.sindex                                            # built once, at start-up
timings = {}
with timed("query", timings):
    subset = layer.cx[x0:x1, y0:y1]
with timed("serialise", timings):
    body = subset.to_json()
print(f"{len(subset):,} features; query {timings['query'] * 1000:.2f} ms, "
      f"serialise {timings['serialise'] * 1000:,.0f} ms, {len(body) / 1e6:.2f} MB")
1,613 features; query 4.39 ms, serialise 351 ms, 15.56 MB

On a GeoPandas-backed service the serialisation is frequently larger than the query: measured, a bounding-box selection over Europe from 4,596 polygons took 4.39 ms, while serialising the 1,613 features it found, 15.56 MB of GeoJSON, took 351 ms — 80 times as long.

5. Then scale out, with the right worker count

uvicorn service:app --workers $(( $(nproc) )) --loop uvloop --http httptools

Workers multiply throughput for CPU-bound serialisation and multiply memory. A service holding a 500 MB GeoDataFrame in module scope needs 500 MB per worker unless the data is shared or moved to a database.

Check memory before raising the worker count, or the fix is an out-of-memory kill.

6. Put a cache in front

A CDN or a caching proxy converts repeated identical requests into zero application requests. For tiles at versioned URLs marked immutable, that is close to all of them.

This is the step that changes the shape of the load rather than the speed of the handler, and it is why the caching headers in step 3 matter more than any code optimisation.

A synchronous call inside an async endpoint stops every other request in that worker. one thread, one blocking calltime.sleep in async defrequest 1 blocks the looprequests 2–10 waitthen run, one at a time19.8 rps · p50 504 msawaitable, or a plain defall in flight together175.6 rps · p50 54 msFastAPI runs plain def handlers in a thread pool — for GeoPandas that is the right default spatialworkflow.io
A 50 ms synchronous call stops every other request in that worker for 50 ms.

Finding blockers and load-testing the fixes

The data used on this page

provinces.gpkg is Natural Earth's 1:10m admin-1 states and provinces layer: 4,596 polygons with 121 attribute columns. Example 4 serves it from a single uvicorn worker in a separate process and loads it with Example 2's test, ten concurrent clients on the same machine, with the load average printed beside the timings.

import os

import geopandas as gpd

if not os.path.exists("provinces.gpkg"):
    gpd.read_file("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_admin_1_states_provinces.zip") \
       .to_file("provinces.gpkg")
layer = gpd.read_file("provinces.gpkg")
print(len(layer), "polygons,", len(layer.columns) - 1, "attribute columns")
4596 polygons, 121 attribute columns

Example 1 — finding blocking calls in async endpoints

import asyncio
import time
import warnings


def install_blocking_detector(threshold_ms=100):
    """Warn when the event loop is blocked longer than the threshold."""
    loop = asyncio.get_event_loop()
    loop.set_debug(True)
    loop.slow_callback_duration = threshold_ms / 1000

    async def monitor():
        while True:
            started = time.perf_counter()
            await asyncio.sleep(0.1)
            drift = (time.perf_counter() - started - 0.1) * 1000
            if drift > threshold_ms:
                warnings.warn(f"event loop blocked for {drift:.0f} ms — "
                              f"a synchronous call in an async endpoint")
    asyncio.create_task(monitor())


async def demo():
    install_blocking_detector(threshold_ms=100)
    await asyncio.sleep(0.15)                  # let the monitor start
    time.sleep(0.7)                            # a synchronous call on the loop
    await asyncio.sleep(0.3)

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    asyncio.run(demo())
print([str(w.message) for w in caught if "blocked" in str(w.message)])
['event loop blocked for 651 ms — a synchronous call in an async endpoint']

The drift measurement is the reliable signal: a loop that should have slept 100 ms and slept 700 ms was blocked for 600 ms by something synchronous.

Example 2 — a load test that reports the numbers that matter

import asyncio
import statistics
import time
import httpx


async def load_test(url, n=200, concurrency=10, headers=None):
    limits = httpx.Limits(max_connections=concurrency)
    async with httpx.AsyncClient(limits=limits, timeout=60) as client:
        await client.get(url, headers=headers or {})          # warm

        semaphore = asyncio.Semaphore(concurrency)
        latencies = []

        async def one():
            async with semaphore:
                started = time.perf_counter()
                response = await client.get(url, headers=headers or {})
                latencies.append(time.perf_counter() - started)
                return response

        started = time.perf_counter()
        responses = await asyncio.gather(*[one() for _ in range(n)])
        wall = time.perf_counter() - started

    latencies.sort()
    size = len(responses[0].content)
    print(f"{url}")
    print(f"  {n} requests, {concurrency} concurrent")
    print(f"  {n / wall:8.1f} rps   p50 {statistics.median(latencies) * 1000:7.1f} ms"
          f"   p95 {latencies[int(0.95 * len(latencies)) - 1] * 1000:7.1f} ms")
    print(f"  {size:,} bytes per response, "
          f"{size * n / wall / 1e6:.1f} MB/s sustained")

Reporting the sustained megabytes per second is what makes the payload problem obvious: the 1,000-feature page sustained 40.1 MB/s at 2.6 requests per second. That service is not slow; it is busy producing bytes.

Example 3 — the fixes, applied

import hashlib
import os
from fastapi import FastAPI, Query, Request, Response
from fastapi.middleware.gzip import GZipMiddleware
import geopandas as gpd
import shapely

app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=1000)          # fix 2

LAYER = gpd.read_file("provinces.gpkg")
LAYER.sindex                                                    # build once
LAYER_VERSION = os.environ.get("LAYER_VERSION", "1")


@app.get("/features")
def features(request: Request, bbox: str | None = None,
             limit: int = Query(50, ge=1, le=200)):             # fix 2: a real cap
    seed = f"{LAYER_VERSION}|{bbox}|{limit}"
    etag = '"' + hashlib.sha256(seed.encode()).hexdigest()[:16] + '"'
    if request.headers.get("if-none-match") == etag:            # fix 3
        return Response(status_code=304, headers={"ETag": etag})

    subset = LAYER
    if bbox:
        x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
        subset = subset.cx[x0:x1, y0:y1]

    page = subset.iloc[:limit].copy()
    page["geometry"] = shapely.set_precision(page.geometry.values, 1e-6)  # fix 2
    return Response(page.to_json(), media_type="application/geo+json",
                    headers={"ETag": etag,
                             "Cache-Control": "public, max-age=300",
                             "Vary": "Accept-Encoding"})


from fastapi.testclient import TestClient

client = TestClient(app)
first = client.get("/features?limit=50")
again = client.get("/features?limit=50", headers={"If-None-Match": first.headers["etag"]})
print(first.status_code, first.headers["content-encoding"], again.status_code,
      client.get("/features?limit=1000").status_code)
200 gzip 304 422

Note that the endpoint is def, not async def. FastAPI runs synchronous handlers in a thread pool, so GeoPandas work does not block the event loop — which is the correct default for a library that has no async interface.

Example 4 — the measurements in the table

import subprocess
import sys

SERVICE = """
import asyncio, hashlib, time
import geopandas as gpd
from fastapi import FastAPI, Request, Response

LAYER = gpd.read_file("provinces.gpkg")
app = FastAPI()
BODY = LAYER.iloc[:50].to_json()                        # one precomputed 50-feature page
ETAG = '"' + hashlib.sha256(BODY.encode()).hexdigest()[:16] + '"'

@app.get("/page")
def page(limit: int = 50):                              # serialised on every request
    return Response(LAYER.iloc[:limit].to_json(), media_type="application/geo+json")

@app.get("/blocking")
async def blocking():                                   # 50 ms of synchronous work on the loop
    time.sleep(0.05)
    return Response(BODY, media_type="application/geo+json")

@app.get("/awaiting")
async def awaiting():                                   # the same 50 ms, awaited
    await asyncio.sleep(0.05)
    return Response(BODY, media_type="application/geo+json")

@app.get("/cached")
def cached(request: Request):
    if request.headers.get("if-none-match") == ETAG:
        return Response(status_code=304, headers={"ETag": ETAG})
    return Response(BODY, media_type="application/geo+json", headers={"ETag": ETAG})
"""
open("bench_service.py", "w").write(SERVICE)
server = subprocess.Popen([sys.executable, "-m", "uvicorn", "bench_service:app", "--port", "8771",
                           "--log-level", "warning"])
BASE = "http://127.0.0.1:8771"
while True:
    try:
        httpx.get(f"{BASE}/cached", timeout=1)
        break
    except httpx.TransportError:
        time.sleep(0.2)

print(f"one worker, load average {os.getloadavg()[0]:.1f}")
asyncio.run(load_test(f"{BASE}/page?limit=50", n=100))
asyncio.run(load_test(f"{BASE}/page?limit=1000", n=20))
asyncio.run(load_test(f"{BASE}/blocking"))
asyncio.run(load_test(f"{BASE}/awaiting"))
asyncio.run(load_test(f"{BASE}/cached"))
etag = httpx.get(f"{BASE}/cached").headers["etag"]
asyncio.run(load_test(f"{BASE}/cached", n=1000, headers={"If-None-Match": etag}))
server.terminate()
server.wait()

# payload levers, on the whole layer and on one page
full = layer.to_json()
rounded = layer.assign(geometry=shapely.set_precision(layer.geometry.values, 1e-6)).to_json()
print(f"whole layer {len(full) / 1e6:.2f} MB, coordinates at 1e-6 degrees {len(rounded) / 1e6:.2f} MB "
      f"({1 - len(rounded) / len(full):.0%} smaller)")
import gzip
page50 = layer.iloc[:50].to_json().encode()
print(f"50-feature page {len(page50):,} bytes, gzip level 9 {len(gzip.compress(page50)):,} "
      f"({len(page50) / len(gzip.compress(page50)):.2f}x)")
one worker, load average 0.5
http://127.0.0.1:8771/page?limit=50
  100 requests, 10 concurrent
      23.1 rps   p50   422.1 ms   p95   482.9 ms
  1,010,943 bytes per response, 23.3 MB/s sustained
http://127.0.0.1:8771/page?limit=1000
  20 requests, 10 concurrent
       2.6 rps   p50  3465.5 ms   p95  4313.4 ms
  15,590,201 bytes per response, 40.1 MB/s sustained
http://127.0.0.1:8771/blocking
  200 requests, 10 concurrent
      19.8 rps   p50   504.4 ms   p95   505.5 ms
  1,010,943 bytes per response, 20.0 MB/s sustained
http://127.0.0.1:8771/awaiting
  200 requests, 10 concurrent
     175.6 rps   p50    54.1 ms   p95    63.9 ms
  1,010,943 bytes per response, 177.5 MB/s sustained
http://127.0.0.1:8771/cached
  200 requests, 10 concurrent
     586.4 rps   p50    16.3 ms   p95    19.3 ms
  1,010,943 bytes per response, 592.8 MB/s sustained
http://127.0.0.1:8771/cached
  1000 requests, 10 concurrent
    1318.3 rps   p50     5.3 ms   p95    13.0 ms
  0 bytes per response, 0.0 MB/s sustained
whole layer 68.35 MB, coordinates at 1e-6 degrees 46.44 MB (32% smaller)
50-feature page 1,010,943 bytes, gzip level 9 337,521 (3.00x)

Why one blocking call stalls everything

Why a blocking call is so much worse than it looks

A single worker's event loop is one thread. A 50 ms synchronous call does not slow that request by 50 ms — it stops every other request in that worker for 50 ms.

With ten concurrent clients the effect compounds into the measured 19.8 requests per second — exactly one per 50 ms — against 175.6 for the same work done without blocking. The fix is either an async library, run_in_threadpool, or removing the async keyword so FastAPI handles the threading.

Why payload size dominates spatial APIs

Most REST APIs return a few kilobytes. A spatial API returns geometry, and the measured spread is enormous: a 50-feature page at 1.01 MB, a 1,000-feature page at 15.59 MB, the unfiltered layer at 68.35 MB.

Serialisation is CPU work proportional to the output, and transfer is bandwidth proportional to the output. Both grow with the payload: 15.4 times the bytes cost 8.9 times the throughput, the difference being the fixed cost every request pays regardless of size.

That is also why a bigger machine helps less than expected: it does not make the response smaller.

Why caching beats every code optimisation

The measured 304 path served 1,318 requests per second because it did essentially nothing: compare a string, return a header. No query, no serialisation, no body.

No amount of query tuning approaches that, because the fastest possible query is still slower than not running one. And within a max-age window the request does not even arrive.

Caching is therefore the first thing to add and the last thing to remove, and its effectiveness is why response-size and blocking fixes should be judged on the uncached path only.

Why more workers is the last step

Workers multiply throughput and memory. A service holding a 500 MB layer in module scope costs 500 MB per worker; eight workers is 4 GB before any request is served.

They also do not fix the two largest problems. Eight workers each blocked on a synchronous call are eight blocked workers, and eight workers each serialising 15.6 MB are busy producing bytes nobody should have asked for. Fix the handler, then scale it.

The order to fix a slow spatial API. cheapest first, workers last1find the blocking call4.2× measured2shrink the response14× between page sizes3add caching11.9× on the 304 path4profile the handlerserialisation often beats the query5then add workersand check memory per workerJudge each fix on the uncached path, then put the cache back spatialworkflow.io
A bounding-box selection took 4.39 ms; serialising what it found took 351 ms.

Thread pools, per-worker data and sustained throughput

  • A def endpoint in FastAPI runs in a thread pool; async def does not. Choose deliberately.
  • run_in_threadpool is the escape hatch for synchronous work inside an async handler.
  • Module-level GeoDataFrames are per worker. Check memory before scaling out.
  • Build sindex at start-up, not on the first request.
  • Measure sustained MB/s, not just requests per second — it makes saturation obvious.
  • Warm the service before load testing, or the first request's imports dominate.
  • --loop uvloop --http httptools is a small free improvement in uvicorn.
  • Judge fixes on the uncached path, then add the cache back.

FAQ

Why is my API fast in testing and slow under load?

Usually one of four things: a blocking call in an async endpoint, an oversized response, no caching, or a single worker. Measured, the first three were worth 8.9×, 8.9× and 25× (plus 2.2× for a 304); the fourth is worth at most the worker count.

How much does a blocking call cost?

Measured with a 50 ms synchronous call inside async def: 19.8 requests per second against 175.6 for the same wait awaited.

Should I add more workers?

Last. Eight workers each blocked, or each serialising 15.6 MB, are eight times the problem. Fix the handler first, and check memory — module-level data is per worker.

What is the single biggest improvement?

Caching. Serialising a 50-feature page on every request ran at 23.1 requests per second; a stored body at 586.4, and a conditional request answered with 304 at 1,318.3.

How do I know whether the payload is the problem?

Compare throughput at two page sizes. Measured, 50 features gave 23.1 requests per second and 1,000 gave 2.6, and the larger page sustained 40.1 MB/s: the bytes are the work.

Is async def always better?

No. FastAPI runs plain def handlers in a thread pool, which is the right choice for synchronous libraries such as GeoPandas. async def is only better when the work is genuinely awaitable.