Fixing an API Worker That Runs Out of Memory
On this page
Problem statement
The worker is killed. Sometimes there is an exception; more often the process simply disappears and the supervisor restarts it:
Worker (pid:1247) was sent SIGKILL! Perhaps out of memory?
For a spatial API there are four distinct causes, and they need different fixes:
- The layer is loaded per worker. A 500 MB GeoDataFrame in module scope costs 500 MB Γ the worker count. Eight workers is 4 GB before a request arrives.
- A single response is enormous. Measured on Natural Earth's 4,596 provinces, an unfiltered feature response was 68.35 MB and a 1,000-feature page was 15.6 MB β and the body is built in memory before it is sent.
- Concurrent large requests multiply. Ten clients each triggering a 15.6 MB response is 156 MB of response bodies alive at once, plus the intermediate structures that produced them.
- Something accumulates. A cache with no bound, a list of results, a connection pool that grows.
The first two are architecture; the last two are bugs. Telling them apart takes one measurement each.
Quick answer
import resource
import os
def memory_report(label=""):
peak_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
with open("/proc/self/status") as status:
current = next((line for line in status if line.startswith("VmRSS")), "")
print(f"{label:24} peak {peak_mb:8,.0f} MB {current.strip()}")
@app.on_event("startup")
def on_startup():
memory_report("after loading layers") # this Γ workers is your floor
Then the four fixes, in order of how often they apply:
# 1. do not hold the data per worker
LAYER = None # load from a database or a file per request
# 2. bound every response
limit: int = Query(50, ge=1, le=200)
# 3. stream anything genuinely large
return StreamingResponse(generate(), media_type="application/geo+json-seq")
# 4. bound concurrency on expensive endpoints
HEAVY = asyncio.Semaphore(4)
Baseline, shared data, bounded responses, streaming
1. Measure the per-worker baseline
The floor for the whole service is the memory one worker uses after start-up, multiplied by the worker count. Print it at start-up and multiply:
after loading layers peak 284 MB VmRSS: 283964 kB
That is the server on this page after loading one 4,596-polygon layer. Eight workers of it is 2.3 GB, idle, before a single response has been built. On a 2 GB container the service is dead before the first request, and no request-level optimisation helps.
2. Stop holding the data per worker
Three ways out, in increasing order of effort:
- Query a database instead. PostGIS or a DuckDB file holds the data once, outside the workers.
- Read from a file per request. A Parquet or FlatGeobuf file with a spatial filter reads only what is needed; the operating system's page cache is shared between workers.
- Use fewer workers with more threads. Synchronous FastAPI handlers run in a thread pool, so one worker with sixteen threads serves concurrent requests without duplicating the data.
The third is frequently the quickest fix and it is genuinely effective for I/O-bound handlers.
3. Bound the response, because the body is built in memory
to_json() produces the entire string before it is sent. Measured, building a 15.6 MB body of 1,000 features peaked at 67.7 MB of Python allocations β 4.3 times the final size, alive at once.
Ten concurrent such requests is not 156 MB but closer to 680 MB. A page-size cap is therefore a memory control as much as a performance one:
limit: int = Query(50, ge=1, le=200)
4. Stream what genuinely has to be large
A streamed response holds one chunk at a time:
def generate():
for start in range(0, len(subset), 500):
page = subset.iloc[start:start + 500]
for feature in json.loads(page.to_json())["features"]:
yield json.dumps(feature) + "\n"
return StreamingResponse(generate(), media_type="application/geo+json-seq")
The peak becomes a property of the chunk size rather than of the result. The laziness has to reach the data source, though: a generator whose first line calls .df() has streamed nothing.
5. Bound the concurrency of expensive endpoints
A rate limit bounds arrivals; a semaphore bounds how many expensive requests are in flight at once:
import asyncio
from fastapi import HTTPException
HEAVY = asyncio.Semaphore(4)
@app.get("/download")
async def download():
try:
await asyncio.wait_for(HEAVY.acquire(), timeout=0.5)
except asyncio.TimeoutError:
raise HTTPException(503, "too many exports in progress",
headers={"Retry-After": "30"})
try:
return await build_export()
finally:
HEAVY.release()
Four concurrent 15.6 MB responses is 62 MB of bodies and, at 4.3 times, about 270 MB at the peak; forty is 2.7 GB and a dead worker.
6. Find the leak, if memory grows rather than spikes
A spike is a big request; growth is a leak. The difference is visible in a graph and diagnosable with tracemalloc:
import tracemalloc
tracemalloc.start(10)
baseline = tracemalloc.take_snapshot()
# ... serve some requests ...
current = tracemalloc.take_snapshot()
for stat in current.compare_to(baseline, "lineno")[:10]:
print(stat)
The usual culprits in a spatial API: an unbounded functools.lru_cache on a function returning GeoDataFrames, a module-level list appended per request, and a connection pool with no maximum.
Memory-aware start-up and per-request reads
The data used on this page
The layer is Natural Earth's 1:10m admin-1 states and provinces (4,596 polygons, 121 attributes), as provinces.gpkg and provinces.parquet. The setup writes oom_server.py β a FastAPI app that loads the whole layer at module scope, the pattern this page warns about, with the /health endpoint from Example 1 β and starts one uvicorn worker on port 8765. Every measurement on the page is taken against that process.
import atexit
import os
import subprocess
import sys
import time
import geopandas as gpd
import httpx
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")
if not os.path.exists("provinces.parquet"):
gpd.read_file("provinces.gpkg").to_parquet("provinces.parquet")
with open("oom_server.py", "w") as f:
f.write('''
import os
import resource
import geopandas as gpd
from fastapi import FastAPI, Query, Response
def memory_report(label=""):
peak_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
with open("/proc/self/status") as status:
current = next((line for line in status if line.startswith("VmRSS")), "")
print(f"{label:24} peak {peak_mb:8,.0f} MB {current.strip()}", flush=True)
def rss_mb():
with open("/proc/self/statm") as handle:
pages = int(handle.read().split()[1])
return pages * os.sysconf("SC_PAGE_SIZE") / 1e6
LAYER = gpd.read_file("provinces.gpkg") # module scope: one copy per worker
app = FastAPI()
BASELINE = rss_mb()
memory_report("after loading layers")
@app.get("/features")
def features(limit: int = Query(50, ge=1, le=5000)):
return Response(LAYER.iloc[:limit].to_json(), media_type="application/geo+json")
@app.get("/health")
def health():
current = rss_mb()
return {"rss_mb": round(current), "growth_mb": round(current - BASELINE),
"peak_mb": round(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024)}
''')
BASE = "http://127.0.0.1:8765"
server = subprocess.Popen([sys.executable, "-m", "uvicorn", "oom_server:app", "--port", "8765",
"--workers", "1", "--log-level", "warning"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
atexit.register(server.terminate)
startup_line = server.stdout.readline().rstrip()
for _ in range(120):
try:
httpx.get(f"{BASE}/health")
break
except httpx.ConnectError:
time.sleep(0.5)
print(startup_line)
after loading layers peak 284 MB VmRSS: 283964 kB
Example 1 β a memory-aware start-up and health check
import os
import resource
from fastapi import FastAPI
app = FastAPI()
def rss_mb() -> float:
with open("/proc/self/statm") as handle:
pages = int(handle.read().split()[1])
return pages * os.sysconf("SC_PAGE_SIZE") / 1e6
@app.on_event("startup")
def report_baseline():
app.state.baseline_mb = rss_mb()
workers = int(os.environ.get("WEB_CONCURRENCY", "1"))
print(f"baseline {app.state.baseline_mb:,.0f} MB per worker; "
f"{workers} workers β {app.state.baseline_mb * workers:,.0f} MB floor")
@app.get("/health")
def health():
current = rss_mb()
growth = current - app.state.baseline_mb
return {"status": "ok" if growth < 500 else "degraded",
"rss_mb": round(current),
"growth_mb": round(growth),
"peak_mb": round(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024)}
Exposing growth in the health check is what turns "the workers keep restarting" into a graph with a slope.
Example 2 β reading per request instead of holding the layer
import geopandas as gpd
from fastapi import FastAPI, Query, Response
app = FastAPI()
LAYER_PATH = "provinces.parquet"
@app.get("/features")
def features(bbox: str | None = None, limit: int = Query(50, ge=1, le=200)):
"""Read only the rows the request needs. The OS page cache is shared
between workers; a module-level GeoDataFrame is not."""
if bbox:
x0, y0, x1, y1 = (float(v) for v in bbox.split(","))
subset = gpd.read_parquet(LAYER_PATH, bbox=(x0, y0, x1, y1))
else:
subset = gpd.read_parquet(LAYER_PATH, columns=["name", "admin", "geometry"])
return Response(subset.iloc[:limit].to_json(),
media_type="application/geo+json")
GeoParquet's row-group statistics mean a bounding-box read touches a fraction of the file. In Columnar vs row storage, a selective query against a 446 MB Parquet file transferred 0.26 MB β the same principle applies to a local read.
Example 3 β a load test that watches memory
import asyncio
import httpx
async def memory_under_load(base_url, path, rounds=5, n=100, concurrency=10):
"""Growth across rounds is a leak; a flat line with spikes is not."""
async with httpx.AsyncClient(timeout=60) as client:
for round_number in range(1, rounds + 1):
limits = httpx.Limits(max_connections=concurrency)
async with httpx.AsyncClient(limits=limits, timeout=60) as worker:
semaphore = asyncio.Semaphore(concurrency)
async def one():
async with semaphore:
await worker.get(f"{base_url}{path}")
await asyncio.gather(*[one() for _ in range(n)])
health = (await client.get(f"{base_url}/health")).json()
print(f"round {round_number}: rss {health['rss_mb']:,} MB "
f"(+{health['growth_mb']:,} from baseline), "
f"peak {health['peak_mb']:,} MB")
asyncio.run(memory_under_load(BASE, "/features?limit=1000", rounds=3))
round 1: rss 932 MB (+641 from baseline), peak 970 MB
round 2: rss 948 MB (+658 from baseline), peak 971 MB
round 3: rss 959 MB (+668 from baseline), peak 983 MB
Round 1 raised resident memory by 641 MB and it stayed there; rounds 2 and 3 added 17 and 10 MB. One step up and then a plateau is the process keeping the memory that ten concurrent 15.6 MB responses needed at once (Example 5). A leak climbs by a similar amount every round.
Example 4 β the measurements on this page
import asyncio
import time
import tracemalloc
for limit in (50, 1000, 5000):
reply = httpx.get(f"{BASE}/features", params={"limit": limit}, timeout=300)
print(f"limit={limit}: {len(reply.json()['features']):,} features, {len(reply.content) / 1e6:.2f} MB")
layer = gpd.read_file("provinces.gpkg")
tracemalloc.start()
body = layer.iloc[:1000].to_json()
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"building a 1,000-feature body of {len(body) / 1e6:.1f} MB peaked at "
f"{peak / 1e6:.1f} MB of Python allocations ({peak / len(body):.1f}x)")
async def rate(limit, n=40, concurrency=10):
async with httpx.AsyncClient(timeout=300, limits=httpx.Limits(max_connections=concurrency)) as client:
gate = asyncio.Semaphore(concurrency)
async def one():
async with gate:
await client.get(f"{BASE}/features", params={"limit": limit})
start = time.perf_counter()
await asyncio.gather(*[one() for _ in range(n)])
return n / (time.perf_counter() - start)
print(f"load average {os.getloadavg()[0]:.1f}")
for limit in (50, 1000):
print(f"limit={limit}, 10 concurrent: {asyncio.run(rate(limit)):.1f} requests/s")
print("after everything:", httpx.get(f"{BASE}/health").json())
server.terminate()
server.wait()
limit=50: 50 features, 1.01 MB
limit=1000: 1,000 features, 15.59 MB
limit=5000: 4,596 features, 68.35 MB
building a 1,000-feature body of 15.6 MB peaked at 67.7 MB of Python allocations (4.3x)
load average 2.2
limit=50, 10 concurrent: 21.1 requests/s
limit=1000, 10 concurrent: 2.5 requests/s
after everything: {'rss_mb': 1007, 'growth_mb': 717, 'peak_mb': 1064}
Example 5 β is the plateau a leak?
from concurrent.futures import ThreadPoolExecutor
def rss_mb():
with open("/proc/self/statm") as handle:
return int(handle.read().split()[1]) * os.sysconf("SC_PAGE_SIZE") / 1e6
# The same work as the server's burst, in this process, with Python's own allocations traced
before = rss_mb()
tracemalloc.start()
with ThreadPoolExecutor(10) as pool:
list(pool.map(lambda _: len(layer.iloc[:1000].to_json()), range(100)))
live, _ = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"after 100 bodies on 10 threads: rss +{rss_mb() - before:,.0f} MB, "
f"Python objects still alive {live / 1e6:.1f} MB")
# The server again, with glibc limited to two malloc arenas
capped = subprocess.Popen([sys.executable, "-m", "uvicorn", "oom_server:app", "--port", "8765",
"--log-level", "warning"], env=dict(os.environ, MALLOC_ARENA_MAX="2"),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
capped.stdout.readline()
for _ in range(120):
try:
httpx.get(f"{BASE}/health")
break
except httpx.ConnectError:
time.sleep(0.5)
print("MALLOC_ARENA_MAX=2:")
asyncio.run(memory_under_load(BASE, "/features?limit=1000", rounds=3))
capped.terminate()
capped.wait()
after 100 bodies on 10 threads: rss +610 MB, Python objects still alive 3.1 MB
MALLOC_ARENA_MAX=2:
round 1: rss 795 MB (+504 from baseline), peak 3,174 MB
round 2: rss 797 MB (+506 from baseline), peak 3,174 MB
round 3: rss 769 MB (+479 from baseline), peak 3,174 MB
Python itself was holding 3.1 MB of live objects while resident memory stood 610 MB higher than before: nothing leaked, but the freed memory stayed with the process instead of going back to the operating system. Limiting glibc to two arenas lowered the plateau to about 500 MB, and in this run the recorded peak rose to 3.2 GB β a trade, not a fix. What bounds the plateau is how many large bodies can be built at once: the page cap and the semaphore above.
Why module-level data multiplies
Why module-level data multiplies
Each uvicorn or gunicorn worker is a separate process with its own interpreter and its own copy of everything imported at module scope. A 500 MB GeoDataFrame loaded at import is loaded once per worker.
Copy-on-write after a fork helps briefly and stops helping quickly, because Python's reference counting writes to object headers β touching an object dirties its page. Practically, assume no sharing.
The fixes are to move the data out of the process, or to use fewer processes with more threads.
Why the peak is several times the response size
Producing a 15.6 MB GeoJSON response involves the source GeoDataFrame slice, an intermediate representation, the JSON string, and the response body β several copies alive at the same moment. Measured, the peak was 4.3 times the body.
That is why a page-size cap is a memory control. The measured throughput difference between 1.01 MB pages of 50 features and 15.6 MB pages of 1,000 was 21.1 against 2.5 requests per second with ten concurrent clients (load average 2.2), and the memory difference is proportionally worse under concurrency, because ten of them coexist.
Why streaming changes the class of the problem
A streamed response never has a whole body. The generator yields chunks and the server writes them, so the peak is a chunk plus whatever produced it.
That converts memory from a function of the result to a function of the chunk size, which is a constant you choose. It is the only fix that works when the result genuinely has to be large.
Why a semaphore and a rate limit are different controls
A rate limit says how often a caller may ask. A semaphore says how many expensive operations may be in progress at once, across all callers.
Ten different clients each making one legitimate request at the same moment do not breach any rate limit, and if each request costs 100 MB the worker dies. Concurrency is the dimension that matters for memory, and it needs its own bound.
Units, the OOM killer and container limits
ru_maxrssis kilobytes on Linux and bytes on macOS.- A SIGKILL with no traceback is the OS out-of-memory killer, not an application error.
- Container memory limits are invisible to Python β the process sees the host's RAM.
- Copy-on-write does not save you β reference counting dirties pages.
--max-requestsin gunicorn recycles workers and hides a leak rather than fixing it.gc.freeze()after loading static data before forking can help a little.- A
lru_cacheon a function returning GeoDataFrames is an unbounded memory sink. - Measure the baseline per worker and multiply. That number is the floor.
Internal links
- Fixing a spatial API that is slow under load β the related performance failure
- How to stream large query results from a spatial API β the fix for genuinely large results
- How to paginate a large feature API in Python β bounding the response
- Authentication and rate limits for a spatial API β bounding what callers may ask for
- Fixing memory errors in GeoPandas with large files β the library-level problem
- Fixing a batch job whose memory grows β leak diagnosis
- Fixing DuckDB out of memory on a large spatial query β the engine-level version
- How to read a large PostGIS table without exhausting memory β moving the data out of the process
FAQ
Why does my API worker get killed with no traceback?
That is the operating system's out-of-memory killer. Python never sees it, so there is no exception β only a supervisor restarting the process.
Why does memory scale with the worker count?
Because each worker is a separate process with its own copy of everything imported at module scope. A 500 MB layer costs 500 MB per worker, and copy-on-write does not save you.
How much memory does one response need?
About four times its size. A 15.6 MB GeoJSON response peaked at 67.7 MB of allocations while it was built β and ten concurrent ones coexist.
Should I use more workers or more threads?
More threads, for I/O-bound synchronous handlers: FastAPI runs plain def endpoints in a thread pool, and threads share the data that processes duplicate.
How do I tell a leak from a spike?
Run several rounds of load and watch the resident size between them. One step up and then a plateau is memory kept after a burst of big responses; climbing every round is a leak.
What is the quickest fix?
Cap the page size and reduce the worker count. Both take one line, and together they address the two causes that account for most of these failures.