Memory Limit Management for Streamlit and Panel Spatial Dashboards
Spatial analytics dashboards push server and browser memory harder than almost any other Python application. A single user loading a 500 MB GeoJSON, applying a spatial join, and rendering a choropleth can pin hundreds of megabytes of resident memory — and that cost multiplies across every concurrent session a Streamlit or Panel server holds open. When the container crosses its memory ceiling the kernel OOM-killer terminates the process, every active user loses their session state, and the dashboard appears to “randomly crash” under load.
Treating memory as a bounded resource — measured, capped, and reclaimed on a schedule — is what separates a demo that works on your laptop from a deployment that survives a room full of GIS analysts. This page is the memory-control workflow under Caching Strategies & Async Performance Tuning: how to baseline allocation, load spatial data lazily, prune geometry before it reaches a widget, clean up per-session objects, and enforce hard limits at the container boundary. It pairs closely with Query Result Caching for bounding cached payloads and with Async Data Loading Patterns for streaming tiles instead of materializing whole datasets.
Jump to heading The problem: where spatial memory actually goes
Memory pressure in a spatial dashboard rarely comes from one obvious allocation. It accumulates across three layers that each look harmless in isolation:
- Data ingestion. Vector formats deserialize into Shapely geometry objects with significant per-vertex overhead, and
geopandas.read_filematerializes every column whether the map uses it or not. A multi-band GeoTIFF read in full holds the entire raster in RAM before a single tile is drawn. - Framework session state. Both frameworks keep per-user state server-side. When a reader stores a
GeoDataFramein session state and then navigates away without cleanup, that object survives until the session expires — and across many tabs and users it compounds. Understanding the widget lifecycle is what tells you when those objects are actually released. - Caching that never evicts. An unbounded cache is a slow memory leak with good intentions. Without
max_entriesand a TTL, every distinct bounding box a user pans to adds a permanent entry.
The fix is not a single setting; it is a lifecycle that measures, bounds, and reclaims memory at each of these layers.
Jump to heading Prerequisites
This workflow assumes a working spatial dashboard and familiarity with the reactive execution model of your framework. Target versions and tooling:
- Python 3.9+ in an isolated virtual environment.
- Geospatial stack:
geopandas>=0.14,rasterio>=1.3,shapely>=2.0,xarray, andpyproj. Shapely 2.x matters here — its vectorized geometry backend uses substantially less memory than 1.x. - Dashboard framework:
streamlit>=1.28.0orpanel>=1.3.0. - Profiling:
tracemalloc(standard library),psutil, and optionallyobjgraphfor reference-cycle hunting. - Runtime: Docker, Kubernetes, or a managed platform where you can set a hard memory limit and an OOM policy.
- Observability: Prometheus/Grafana or any APM that can scrape RSS and garbage-collection metrics.
Before instrumenting, disable framework auto-reload in production and enable garbage-collection logging — auto-reload retains old module objects and masks real allocation trends. For native allocation tracking, the tracemalloc documentation covers snapshot comparison and top-allocation reporting.
Jump to heading Core implementation workflow
Jump to heading Step 1 — Establish a memory baseline
You cannot bound what you have not measured. Instrument the dashboard with Python’s native tracer and capture peak resident memory during a representative workflow — loading your largest layer, running a spatial join, rendering the map. Start tracemalloc once at process startup, not inside the function, so snapshots remain comparable.
import tracemalloc
import psutil
import os
tracemalloc.start() # call once at startup, not per snapshot
def log_memory_snapshot(label: str) -> float:
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")[:5]
rss_mb = psutil.Process(os.getpid()).memory_info().rss / 1024**2
print(f"[{label}] RSS: {rss_mb:.1f} MB | top allocations:")
for stat in top_stats:
print(f" {stat}")
return rss_mb
Call log_memory_snapshot before and after each heavy operation. The diff tells you whether pressure originates from ingestion, session state, or a third-party library — which determines which of the later steps actually helps.
Jump to heading Step 2 — Implement tiered caching and lazy loading
Spatial data rarely needs full in-memory residency. Replace eager loading with lazy evaluation and a bounded cache. In Streamlit, @st.cache_data with ttl and max_entries keeps growth finite; the eviction policy is the load-bearing part for memory, not the speedup. Pull only the columns the map renders.
import streamlit as st
import geopandas as gpd
@st.cache_data(ttl=3600, max_entries=50)
def load_cached_boundaries(region_id: str) -> gpd.GeoDataFrame:
# Read only the columns the map needs — never the full attribute table.
return gpd.read_file(
f"s3://spatial-data/boundaries/{region_id}.gpkg",
columns=["geometry", "region_name", "population"],
)
For raster-heavy applications, never load entire .tif stacks into RAM. Read on demand with rasterio windows or xarray chunking so visualization is decoupled from full-dataset materialization.
import rasterio
from rasterio.windows import from_bounds
def read_tile(path: str, bbox: tuple[float, float, float, float]):
# bbox in the raster CRS, e.g. EPSG:3857 (web mercator) for a tile request.
with rasterio.open(path) as src:
window = from_bounds(*bbox, transform=src.transform)
return src.read(1, window=window) # only the visible window enters RAM
For decorator-level tuning — hash overrides, show_spinner, and the differences between cache_data and cache_resource — see @st.cache_data Implementation. When the expensive part is a database round-trip rather than a file read, push it down into Query Result Caching so the cached payload is a compact serialized result, not a live GeoDataFrame.
Jump to heading Step 3 — Optimize geospatial data structures
Vector operations are memory-intensive because of geometry serialization and index overhead. Reduce the object before it ever reaches a widget: prune unused columns, simplify geometry to the map scale, and standardize the CRS once so downstream operations do not trigger repeated reprojection. Geometry simplification with CRS normalization up front routinely cuts resident memory by 40–60% without visible loss of detail.
import geopandas as gpd
def optimize_gdf(gdf: gpd.GeoDataFrame, tolerance: float = 0.001) -> gpd.GeoDataFrame:
"""Prune columns, simplify geometry, and standardize CRS to bound memory."""
keep = [c for c in ("geometry", "id", "category") if c in gdf.columns]
gdf = gdf[keep].copy()
gdf = gdf.to_crs(epsg=4326) # WGS84 once, so no per-op reprojection later
gdf["geometry"] = gdf.geometry.simplify(tolerance, preserve_topology=True)
return gdf
Build and cache a spatial index (gdf.sindex) so bounding box filters resolve against the index rather than scanning full geometries. The GeoPandas spatial indexing guide documents the memory-efficient query patterns.
Jump to heading Step 4 — Configure framework limits and session cleanup
Per-session state accumulates silently. Streamlit stores variables in a dict-like session_state; Panel keeps reactive parameters and server contexts. Attach explicit cleanup to navigation events, logout, or a timeout so large objects are released rather than left to the session reaper.
import streamlit as st
import gc
def clear_spatial_cache() -> None:
st.session_state.pop("spatial_data", None) # drop the heavy GeoDataFrame
gc.collect() # break geometry reference cycles
st.rerun()
import panel as pn
import gc
def cleanup_panel_session() -> None:
pn.state.cache.clear() # release server-side cached objects
gc.collect()
Framework cleanup complements but never replaces an infrastructure ceiling. In Docker or Kubernetes, set a hard memory limit and keep the OOM policy enabled so a runaway session is terminated before it degrades the whole node.
Jump to heading Step 5 — Enforce container limits and monitor trends
Export RSS and GC metrics to your observability stack and set thresholds that trigger action rather than just noise:
- Warning: RSS above 70% of the container limit.
- Critical: RSS above 85%, or any swap usage.
- Action: force per-session collection, shed cache entries, or scale horizontally.
Watch the trend. If baseline RSS grows linearly with user count, you have session-state leakage; if it spikes on specific map interactions, profile the render path and tighten tile resolution or simplification tolerance.
Jump to heading Advanced patterns
Out-of-core spatial joins. When a join OOMs, the topology build is the culprit — it materializes both layers in full. Replace it with dask-geopandas, which partitions the join and processes chunks, or pre-filter both inputs with a bounding box so only the relevant geometries are joined.
import dask_geopandas as dgpd
left = dgpd.from_geopandas(parcels, npartitions=8)
right = dgpd.from_geopandas(watersheds, npartitions=8)
joined = dgpd.sjoin(left, right, predicate="intersects").compute()
Per-session memory budgeting. Reject oversized inputs before they reach the cache. Measuring a GeoDataFrame’s real footprint with memory_usage(deep=True) lets you cap what any one session can pin.
def within_budget(gdf, budget_mb: float = 150.0) -> bool:
used_mb = gdf.memory_usage(deep=True).sum() / 1024**2
return used_mb <= budget_mb
Offload state, not geometry. Store only lightweight identifiers — layer IDs, a bounding box, a region key — in session state, and rehydrate the heavy GeoDataFrame from the bounded cache on demand. This keeps per-session memory flat regardless of how many large layers a user has viewed.
Jump to heading Verification & testing
Confirm each control actually bounds memory rather than trusting that it should. Assert that an operation stays within a budget by diffing tracemalloc snapshots:
import tracemalloc
def test_optimize_gdf_reduces_memory(raw_gdf):
tracemalloc.start()
before = tracemalloc.take_snapshot()
optimized = optimize_gdf(raw_gdf, tolerance=0.001)
after = tracemalloc.take_snapshot()
growth = sum(s.size_diff for s in after.compare_to(before, "lineno"))
assert optimized.memory_usage(deep=True).sum() < raw_gdf.memory_usage(deep=True).sum()
assert growth < 50 * 1024**2 # net growth under 50 MB
tracemalloc.stop()
For end-to-end checks, run the dashboard under its real container limit and drive a concurrency soak with memory_profiler:
mprof run streamlit run app.py
mprof plot # inspect the RSS curve for upward drift across sessions
A healthy run shows RSS that rises during load and returns to a stable plateau after cleanup. A staircase that never descends is session-state leakage — return to Step 4.
Jump to heading Troubleshooting
Process killed / OOM during a spatial join
The in-memory topology build holds both layers in full. Switch to dask-geopandas chunked joins, or apply a bounding-box pre-filter to both inputs so only overlapping geometries are materialized. Confirm the win by re-running the join under mprof run.
Dashboard freezes on map pan or zoom
An unbounded tile cache or repeated full-raster reads are saturating memory and the GC. Add max_entries to @st.cache_data and switch raster access to windowed reads (Step 2) so only the visible extent enters RAM. Heavy concurrent fetches should move to Async Data Loading Patterns.
Gradual memory creep over hours of uptime
This is session-state accumulation or unclosed file handles. Attach explicit del plus gc.collect() to navigation and timeout events, and always open rasters with a with rasterio.open(...) context manager so handles close deterministically.
High VSS but low RSS in monitoring
Allocator reservation, not a leak. glibc malloc holds large per-thread arenas; set MALLOC_ARENA_MAX=2 or run the worker under jemalloc, and alert on RSS rather than VSS so the reservation does not trip false OOM warnings.
RSS does not drop after clearing the cache
Freed Python objects are frequently retained by the allocator instead of returned to the OS, so a cleared cache may not move RSS immediately. The memory is reusable by the process; the hard backstop is the container limit. For deterministic release of very large arrays, prefer del plus context managers over relying on gc.collect() alone.
Jump to heading Performance considerations
- Payload thresholds. Keep any single cached
GeoDataFrameunder roughly 150 MB resident; above that, cache a simplified or column-pruned copy instead of the full geometry. Treat raster windows as the unit of caching, never whole files. - Cache key design. Memory and correctness intersect here: hashing raw geometry objects produces duplicate entries for the same shape, inflating the cache. Normalize geometry and key on a stable identifier — the same discipline detailed in Query Result Caching.
- Async vs sync trade-offs. Concurrent loading lowers latency but raises peak memory, since several payloads can be in flight at once. Bound concurrency with a semaphore sized to your memory budget rather than your CPU count, and prefer streaming windowed reads over loading then slicing.
- CRS once, not per operation. Reprojecting inside a hot loop allocates a fresh geometry array each call. Normalize the CRS during ingestion (Step 3) so render-time operations reproject nothing.
Jump to heading Production checklist
- [ ] Baseline RSS captured for the heaviest representative workflow.
-
@st.cache_data/pn.cacheconfigured withmax_entriesandttl. -
GeoDataFrames pruned to essential columns, simplified, and reprojected once. - [ ] Rasters read via windowed/chunked access, never loaded whole.
- [ ] Session cleanup hooks attached to navigation, logout, and timeout.
- [ ] Container memory limit set with OOM policy enabled.
- [ ] RSS, GC pauses, and cache hit rate dashboarded with warning/critical alerts.
Back to Caching Strategies & Async Performance Tuning
Jump to heading Related
- @st.cache_data Implementation — decorator configuration, hash overrides, and eviction tuning for cached GeoDataFrames
- Query Result Caching — deterministic keys and compact serialization that keep cached payloads small
- Async Data Loading Patterns — bounded-concurrency tile streaming that lowers peak memory
- Session State Patterns — storing lightweight identifiers instead of heavy spatial objects per session