Fixing a Spatial API That Is Slow Under Load
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 with ten concurrent clients:
endpoint payload throughput p50 latency
50-feature page 857 kB 32.5 rps 296.7 ms
1,000-feature page 12.43 MB 2.3 rps 4,360.5 ms
blocking call in async def 216 kB 15.7 rps 628.0 ms
same work, async 216 kB 66.7 rps 144.9 ms
precomputed body + ETag 340 kB 100.2 rps 94.6 ms
same, answering 304 0 B 1,191.0 rps 6.2 ms
Four separate fixes, each worth between four and twelve times, 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? 4.2ร 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? 14ร between page sizes
limit: int = Query(50, ge=1, le=200) # not le=10000
# 3. is anything cached? 11.9ร on the 304 path
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: eight workers each serialising a 12.4 MB response are eight times as slow as one worker serialising 857 kB.
Step-by-step solution
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: 15.7 requests per second at 628 ms. The same work with an awaitable sleep: 66.7 requests per second at 145 ms. A factor of 4.2, from one keyword.
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 857 kB page and a 12.4 MB page was 32.5 against 2.3 requests per second โ fourteen times, on the same server, for the same endpoint.
That is not a server problem. Serialising and transmitting fourteen times as much data takes fourteen times as long. Reduce it:
- Cap the page size. 50 features, not 1,000.
- Round the coordinates. Six decimal places took a measured payload from 54.06 MB to 29.56 MB โ a 45% cut.
- Send only the properties the client uses.
- Enable gzip. 857,061 bytes became 299,123 on the wire, a factor of 2.87.
3. Cache, because the cheapest request is one that does no work
Measured: the same precomputed body served 100.2 requests per second at 94.6 ms, and the conditional request answered with 304 served 1,191 requests per second at 6.2 ms.
Twelve 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
timings = {}
with timed("query", timings):
subset = layer.cx[x0:x1, y0:y1]
with timed("serialise", timings):
body = subset.to_json()
print(timings)
On a GeoPandas-backed service the serialisation is frequently larger than the query: measured, a bounding-box selection over 4,596 polygons took 0.43 ms, while producing 857 kB of GeoJSON took hundreds of times that.
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.
Code examples
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())
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: a service pushing 28 MB/s is not slow, it is saturated.
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"})
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.
Explanation
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 15.7 requests per second, against 66.7 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 857 kB, a 1,000-feature page at 12.4 MB, an unfiltered dataset at 54 MB.
Serialisation is CPU work proportional to the output, and transfer is bandwidth proportional to the output. Both scale linearly with the payload, which is why the throughput ratio (32.5 to 2.3) tracks the size ratio (857 kB to 12.4 MB) so closely.
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,191 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 12.4 MB saturate the network rather than the CPU. Fix the handler, then scale it.
Edge cases or notes
- A
defendpoint in FastAPI runs in a thread pool;async defdoes not. Choose deliberately. run_in_threadpoolis the escape hatch for synchronous work inside an async handler.- Module-level GeoDataFrames are per worker. Check memory before scaling out.
- Build
sindexat 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 httptoolsis a small free improvement in uvicorn.- Judge fixes on the uncached path, then add the cache back.
Internal links
- Serving spatial data explained: files, features and tiles โ sizing responses
- How to set cache headers on a spatial API and tile service โ the largest single win
- How to paginate a large feature API in Python โ bounding the payload
- How to add bounding box and attribute filters to a spatial API โ reducing what is returned
- Fixing an API worker that runs out of memory โ the other scaling failure
- How to rate limit and throttle a spatial API โ bounding what clients may ask
- How to profile a slow Python GIS script โ profiling the handler
- How to stream large query results from a spatial API โ for genuinely large results
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, those were worth 4.2ร, 14ร, 11.9ร and the worker count respectively.
How much does a blocking call cost?
Measured with a 50 ms synchronous call inside async def: 15.7 requests per second against 66.7 for the same work done asynchronously.
Should I add more workers?
Last. Eight workers each blocked, or each serialising 12.4 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. A conditional request answered with 304 ran at 1,191 requests per second against 100 for generating the same body.
How do I know whether the payload is the problem?
Compare throughput at two page sizes. Measured, 50 features gave 32.5 requests per second and 1,000 gave 2.3 โ the ratio tracks the payload almost exactly.
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.