Eliminate Latency and Memory Bloat in Python Spatial Dashboards
Spatial dashboard development introduces a class of performance constraints that ordinary web applications rarely encounter. A GeoDataFrame containing 500,000 polygons can consume 2 GB of memory before any rendering begins. Coordinate reference system (CRS) transformations add non-determinism to hash functions. Spatial joins across overlapping geometries saturate CPU cores for several seconds, freezing every Streamlit widget in the process. For data scientists, GIS analysts, and internal tooling teams building production-grade Streamlit or Panel applications, the gap between a usable tool and a bottlenecked prototype is bridged by two engineering disciplines: deterministic caching and non-blocking asynchronous execution.
This guide maps the complete performance architecture for geospatial dashboards. You will learn how to structure cache layers around spatial primitives, prevent UI thread blocking during heavy geoprocessing, enforce memory boundaries in containerised environments, and implement observability that catches degradation before users notice it.
Jump to heading Architecture Overview
The diagram below shows the four-layer performance architecture this guide covers. Data flows from raw spatial sources through a cache and async worker tier before reaching the dashboard UI. Each layer has a distinct responsibility and failure mode.
Jump to heading Foundational Design Constraints for Spatial Workloads
Five constraints shape every architectural decision on this page. Violating any one of them causes failures that are difficult to diagnose because standard profiling tools are not calibrated for geospatial object lifecycles.
1. Geometry hash instability. Python’s default hash function and Streamlit’s built-in object hasher both produce inconsistent results for GeoDataFrame objects. Shapely geometry objects store internal C-level state that changes between instantiations even when the coordinates are identical. Always build cache keys from scalar metadata — CRS EPSG code, bounding box tuple, record count, dataset version slug — never from the geometry column itself.
2. CRS non-determinism. A GeoDataFrame loaded from one source in EPSG:4326 and another in EPSG:3857 represents the same real-world features but compares as entirely different objects. Normalise all incoming data to a single canonical CRS (typically EPSG:4326 for storage, EPSG:3857 for web tile rendering) at the ingestion boundary before any caching occurs. This is covered in depth under Query Result Caching.
3. GIL contention for CPU-bound spatial math. Shapely 1.x operations — unary_union, buffer, intersection, simplify — hold Python’s Global Interpreter Lock (GIL) for the duration of each call. A single complex union operation on a large polygon set can block the entire process for several seconds, freezing every connected dashboard user. Use concurrent.futures.ProcessPoolExecutor to bypass the GIL for these workloads. Shapely 2.x (with GEOS bindings) releases the GIL more frequently but multi-process dispatch is still safer for operations exceeding 500ms.
4. Payload size amplification. A GeoDataFrame serialised with pickle is typically 3–5× larger than the same data in Parquet format. At scale this overhead exhausts both the session cache and the network link between the app server and a Redis-backed external cache. Columnar formats (Parquet, Feather) store geometry as Well-Known Binary (WKB) and apply Zstd or Snappy compression that reduces geometry payload by 40–70%.
5. Session isolation vs. shared cache boundaries. Streamlit runs each user session in a separate thread but within the same process. @st.cache_data is process-wide: a cache entry written by one user is visible to all users. This is a feature for expensive spatial loads (one user pays the cold-start cost, everyone else gets a cache hit) but a security risk if the cached result contains user-specific geometry or attribute filters. Always cache only spatially-neutral, non-personalised datasets at the shared layer, and apply per-user filters downstream in the session context. See Session State Patterns for the correct boundary between shared cache and session-scoped state.
Jump to heading Pattern 1: Deterministic Cache Key Design with @st.cache_data
The foundation of spatial caching in Streamlit is the @st.cache_data implementation. Unlike the deprecated @st.cache, it serialises return values with a copy-on-read contract so mutating a cached GeoDataFrame in one session does not corrupt the cached version. However, the hashing strategy it applies to function arguments must be understood carefully for spatial workloads.
Pass only hashable scalar arguments into cached functions. Load, filter, and transform the GeoDataFrame inside the function body. This guarantees that the cache key is fully determined by the input parameters, not by mutable object state.
import streamlit as st
import geopandas as gpd
import hashlib
@st.cache_data(ttl=3600, max_entries=50)
def load_boundary_layer(
region_slug: str,
crs: str = "EPSG:4326",
bbox: tuple[float, float, float, float] | None = None,
) -> gpd.GeoDataFrame:
"""Load, reproject, and optionally clip a boundary layer.
Args:
region_slug: Identifies the Parquet file (e.g. "uk_lad_2023").
crs: Target CRS as an EPSG string. Applied before caching.
bbox: Optional (minx, miny, maxx, maxy) in WGS-84 to pre-filter rows.
"""
gdf = gpd.read_parquet(f"data/{region_slug}.parquet")
gdf = gdf.to_crs(crs)
if bbox:
# Spatial index query is far cheaper than full-geometry intersection
gdf = gdf.cx[bbox[0]:bbox[2], bbox[1]:bbox[3]]
return gdf.reset_index(drop=True)
The diagram below contrasts the two key-construction strategies. Passing the GeoDataFrame itself produces an unstable hash and a permanent cache miss; passing scalar metadata produces a stable key that hits on every repeat request.
Key design rules:
- Cache at the query boundary, not the UI boundary. Load raw spatial assets once and cache the filtered, reprojected output. User interactions should trigger lightweight secondary queries against cached subsets rather than full dataset reloads.
- Use bounding-box pre-filtering before caching.
gdf.cx[xmin:xmax, ymin:ymax]uses the spatial index built at read time and avoids loading coordinates outside the viewport. Combine this with Query Result Caching at the PostGIS tier for maximum savings. - Version your dataset paths. Embed a date or content hash in the filename (
uk_lad_2023-11-01.parquet) so TTL expiry and schema changes both invalidate the cache cleanly.
Jump to heading Pattern 2: Decoupling Heavy Geoprocessing with Async Execution
Even with aggressive caching, initial dashboard loads and complex spatial operations — Voronoi tessellation, network routing, raster-to-vector conversion, dissolve on large polygon sets — will block the main thread. In Streamlit’s single-process model, a blocked thread means frozen widgets for every active session, and a stalled re-run can leave the widget lifecycle in an inconsistent state until the operation returns.
The async data loading patterns used in production fall into two categories:
- I/O-bound work (fetching WMS tiles, querying external APIs, reading remote Parquet from S3): use
asynciowithaiohttporhttpxinasync/awaitstyle. See Using asyncio for Concurrent Map Tile Loading in Python for a worked example. - CPU-bound work (geometry unions, dissolves, network analysis): use
concurrent.futures.ProcessPoolExecutorto bypass the GIL and distribute work across cores.
import asyncio
import concurrent.futures
import io
import geopandas as gpd
import streamlit as st
# Module-level executor — reused across requests, avoids repeated process spawning
_cpu_pool = concurrent.futures.ProcessPoolExecutor(max_workers=4)
def _dissolve_by_category(parquet_path: str, category: str) -> bytes:
"""CPU-bound spatial dissolve — runs in a worker process, returns WKB bytes."""
gdf = gpd.read_parquet(parquet_path)
dissolved = gdf[gdf["category"] == category].dissolve(by="region_id")
# Serialise to WKB so the result crosses the process boundary cheaply
dissolved["geometry"] = dissolved["geometry"].to_wkb()
return dissolved.to_parquet()
async def dissolve_async(parquet_path: str, category: str) -> gpd.GeoDataFrame:
loop = asyncio.get_running_loop()
raw = await loop.run_in_executor(_cpu_pool, _dissolve_by_category, parquet_path, category)
return gpd.GeoDataFrame(gpd.read_parquet(io.BytesIO(raw)))
def spatial_analysis_page():
category = st.selectbox("Category", ["residential", "commercial", "industrial"])
if st.button("Run Dissolve"):
with st.spinner("Dissolving geometries…"):
result = asyncio.run(dissolve_async("data/uk_parcels_2024.parquet", category))
st.map(result.to_crs("EPSG:4326"))
Progressive rendering is the complementary pattern: render a lightweight centroid or convex-hull layer immediately from cached data, then swap in the full polygon layer when the async operation completes. Users see a responsive map within 100ms even during multi-second computations.
Jump to heading Pattern 3: Columnar Serialisation and Payload Reduction
Caching and async execution only solve part of the problem. If serialised payloads are oversized or memory allocation is unbounded, the application will still hit resource ceilings under moderate concurrency. Geospatial objects contain redundant coordinate arrays, topology metadata, and index structures that pickle handles inefficiently.
Use Apache Arrow and Parquet for all cache serialisation above a few kilobytes:
import io
import pyarrow as pa
import pyarrow.parquet as pq
import geopandas as gpd
def gdf_to_parquet_bytes(gdf: gpd.GeoDataFrame, columns: list[str]) -> bytes:
"""Compact serialisation: drop redundant columns, store geometry as WKB.
WKB + Zstd typically achieves 50-65% size reduction vs pickle for polygon data.
"""
subset = gdf[columns + ["geometry"]].copy()
subset["geometry"] = subset["geometry"].to_wkb()
table = pa.Table.from_pandas(subset)
buf = io.BytesIO()
pq.write_table(table, buf, compression="zstd")
return buf.getvalue()
def parquet_bytes_to_gdf(data: bytes, geom_col: str = "geometry") -> gpd.GeoDataFrame:
gdf = gpd.read_parquet(io.BytesIO(data))
gdf[geom_col] = gpd.GeoSeries.from_wkb(gdf[geom_col])
return gpd.GeoDataFrame(gdf, geometry=geom_col, crs="EPSG:4326")
Apply geometry simplification for rendering tiers. Caching multiple resolution levels — full fidelity for export, 1m tolerance for zoom level 12, 100m tolerance for zoom level 6 — prevents the frontend from downloading coordinate precision it cannot display:
@st.cache_data(ttl=7200, max_entries=30)
def load_simplified(region_slug: str, tolerance_m: float) -> gpd.GeoDataFrame:
gdf = gpd.read_parquet(f"data/{region_slug}.parquet").to_crs("EPSG:27700")
gdf["geometry"] = gdf["geometry"].simplify(tolerance_m, preserve_topology=True)
return gdf.to_crs("EPSG:4326")
Jump to heading Production Configuration: Container Limits, Cache Sizing, and Auth Hooks
Jump to heading Memory limits and cache eviction
Setting max_entries on @st.cache_data is insufficient on its own because Streamlit does not evict entries based on byte count. A single entry containing a 300 MB GeoDataFrame can exhaust container memory before the entry count ceiling triggers. Pair entry limits with explicit container memory constraints:
Kubernetes deployment snippet:
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
livenessProbe:
httpGet:
path: /_stcore/health
port: 8501
initialDelaySeconds: 30
periodSeconds: 15
At 2 Gi limit, configure @st.cache_data(max_entries=25) for layers that may reach 50 MB each. Monitor RSS via /proc/self/status and trigger gc.collect() proactively when memory approaches 75% of the limit.
For memory limit management in serverless environments (Cloud Run, AWS Lambda), set the framework’s memory allocation to at least 3× the size of the largest single cached object. Spatial workloads exhibit bursty allocation: loading a 200 MB Parquet file can temporarily consume 600 MB due to Arrow → Pandas → GeoDataFrame conversion intermediates.
Jump to heading Auth hooks and session isolation
Internal dashboards serving sensitive geometry — property boundaries, facility locations, demographic overlays — must enforce row-level filtering after the shared cache layer, not before it. The shared cache stores the full unfiltered dataset; the auth check trims it per session:
import streamlit as st
def get_authorised_view(
full_gdf: gpd.GeoDataFrame,
user_regions: list[str],
) -> gpd.GeoDataFrame:
"""Apply per-user region filter after retrieving the shared cached dataset."""
if not user_regions:
st.error("No authorised regions assigned to this account.")
st.stop()
return full_gdf[full_gdf["region_code"].isin(user_regions)]
# Usage in the page
full_layer = load_boundary_layer("uk_lad_2023") # shared cache hit
view = get_authorised_view(full_layer, st.session_state.get("user_regions", []))
See implementing role-based access control for internal dashboards for the full RBAC pattern including JWT claim extraction and audit logging.
Jump to heading ThreadPoolExecutor sizing for WMS tile fetching
For concurrent tile loading (WMS/WMTS), size the thread pool at min(32, os.cpu_count() + 4) following Python’s own ThreadPoolExecutor default for I/O-bound work. WMS servers typically impose a per-IP concurrency limit; cap at 8–12 concurrent requests to avoid 429 throttling responses:
import os
import concurrent.futures
TILE_FETCH_WORKERS = min(12, os.cpu_count() + 4)
_tile_pool = concurrent.futures.ThreadPoolExecutor(max_workers=TILE_FETCH_WORKERS)
Jump to heading Observability & Failure Modes
Jump to heading What to instrument
Three metrics signal spatial performance degradation before users notice:
| Metric | Collection method | Alert threshold |
|---|---|---|
spatial_cache_hit_ratio | Decorator wrapper counting hits vs. calls | < 0.70 over 5 min |
async_task_queue_depth | _cpu_pool._work_queue.qsize() | > 2× worker count |
gdf_serialisation_bytes | len(gdf_to_parquet_bytes(...)) logged per call | > 100 MB per object |
import logging
import time
from functools import wraps
logger = logging.getLogger("spatial.perf")
def track_spatial_op(func):
"""Decorator that logs duration, row count, and memory delta for spatial functions."""
import tracemalloc
@wraps(func)
def wrapper(*args, **kwargs):
tracemalloc.start()
t0 = time.perf_counter()
result = func(*args, **kwargs)
duration = time.perf_counter() - t0
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
row_count = len(result) if hasattr(result, "__len__") else -1
logger.info(
"%s duration=%.3fs rows=%d peak_mem_mb=%.1f",
func.__name__, duration, row_count, peak / 1_048_576
)
return result
return wrapper
Jump to heading Common failure signatures
UnhashableTypeError: unhashable type: GeoDataFrame — You passed a GeoDataFrame directly as an argument to a @st.cache_data function. Move the GeoDataFrame construction inside the cached function and pass only scalar identifiers as arguments.
MemoryError on gdf.to_crs() — CRS transformation materialises a second copy of the entire coordinate array in memory. For datasets above 500 MB, transform in chunks using gdf.iloc[i:i+50000].to_crs(crs) and concatenate, or pre-transform at ingestion time and store in the target CRS.
Streamlit DuplicateWidgetID after async task completes — Background tasks that call st. APIs outside the main execution path trigger widget ID collisions on re-run. Communicate results through st.session_state keys and let the normal Streamlit execution path render the output.
ProcessPoolExecutor deadlock on macOS / Python 3.12 — The spawn start method (default on macOS) requires all arguments to be picklable. GeoDataFrame objects with custom geometry types may fail silently. Always serialise to WKB bytes before crossing the process boundary (as shown in Pattern 2 above).
PostGIS ST_Intersects returning stale geometry — If the cache TTL outlives a scheduled data refresh, users will see an earlier data version. Use versioned table names or a data_version column as part of the cache key rather than relying solely on TTL.
Jump to heading Compliance and Security Notes for Spatial Data
Geometry is often personally identifiable. A polygon representing a user’s home address, a GPS trajectory revealing daily movements, or facility boundaries that expose security-sensitive infrastructure all require the same rigour as personal data under GDPR and sector-specific regulations.
Geometry masking at the cache boundary. Do not cache raw precision coordinates for restricted datasets. Apply gdf.simplify(tolerance=0.001) or centroid-only representation before the shared cache layer. Reserve full-precision geometry for queries authenticated to specific roles.
Audit logging for spatial queries. Log the bounding box, CRS, user identity, and query timestamp for every spatial query that touches restricted datasets. This creates an audit trail that satisfies data governance requirements without exposing the geometry itself:
import logging
audit = logging.getLogger("spatial.audit")
def log_spatial_query(user_id: str, bbox: tuple, dataset: str) -> None:
audit.info(
"spatial_query user=%s dataset=%s bbox=%s",
user_id, dataset, bbox
)
RBAC enforcement for data flow architectures. When spatial data flows through reactive update chains — slider changes the bounding box, which triggers a cache lookup, which feeds a map layer — each step must carry the user’s authorisation context. Do not cache the result of a user-specific query in the shared cache.
Tile URL signing. WMS and raster tile endpoints that require authentication should use short-lived signed URLs (e.g. AWS S3 presigned URLs, Azure SAS tokens) rather than embedding API keys in the dashboard code or client-side JavaScript. Rotate signing credentials on a schedule and do not cache signed URLs beyond their expiry window.
Jump to heading Conclusion
Building production-grade spatial dashboards requires moving deliberately beyond default framework behaviours. Deterministic cache keys built from scalar metadata — not raw geometry — prevent spurious cache misses. CRS normalisation at ingestion time eliminates a class of non-determinism that standard profiling tools cannot detect. Bounding-box pre-filtering via spatial indexes reduces both I/O volume and cache footprint before a single Python object is created.
Offloading CPU-bound geoprocessing to ProcessPoolExecutor keeps the Streamlit event thread responsive under concurrent load. Columnar serialisation with Parquet and WKB geometry reduces cache payload by 40–70% compared to pickle. Container memory limits paired with max_entries caps prevent the bursty allocation characteristic of spatial workloads from cascading into OOM kills.
Add structured telemetry around cache hit ratios, queue depth, and per-operation memory peaks from day one. These metrics expose the feedback loop that lets you tighten TTLs, adjust entry limits, and right-size worker pools as data volumes and user concurrency grow. Spatial performance degradation is rarely sudden — it accumulates quietly in cache miss rates and allocation graphs before it surfaces as user-visible slowness.
The topics below provide implementation depth for each sub-system: the @st.cache_data implementation for hash-stable keys, Query Result Caching for the database tier, Async Data Loading Patterns for non-blocking execution, and Memory Limit Management for containerised eviction policies.
Jump to heading Frequently asked questions
Why does @st.cache_data produce cache misses for GeoDataFrames?
Streamlit hashes the entire object including mutable internal state, index offsets, and floating-point geometry arrays that differ even when the logical data is identical. Two GeoDataFrame objects that represent the same features but were built from different sources hash differently. Pass only lightweight scalar parameters — region slug, CRS string, bounding-box tuple — into cached functions and load the GeoDataFrame inside the function body so the cache key is fully stable. The @st.cache_data implementation page covers the full key-construction pattern.
Should I use ProcessPoolExecutor or ThreadPoolExecutor for spatial geoprocessing?
For pure-Python CPU work that holds the GIL — most Shapely 1.x operations such as unary_union, buffer, and intersection — use ProcessPoolExecutor to bypass the GIL and distribute across cores. For I/O-bound work (WMS fetches, PostGIS queries) or Shapely 2.x operations that release the GIL, ThreadPoolExecutor is sufficient and cheaper because it avoids inter-process pickle overhead. The async data loading patterns page works through both dispatch styles.
How do I prevent out-of-memory kills in a containerised spatial dashboard?
Set @st.cache_data(max_entries=N) and @st.cache_resource(max_entries=M) so Streamlit evicts the oldest entries at the limit, then pair these with a hard container memory limit (for example 2Gi in Kubernetes) and a liveness probe that restarts pods whose RSS exceeds 80% of the limit. Because max_entries does not bound bytes, a single oversized layer can still exhaust memory — see Memory Limit Management for RSS monitoring and proactive gc.collect() triggers.
Back to Spatial Dashboard Home
Jump to heading Related
- Async Data Loading Patterns — concurrency patterns for WMS tile fetching and GeoDataFrame streaming
@st.cache_dataImplementation for Spatial Workloads — hash-stable caching with TTL, entry limits, and custom serialisers- Query Result Caching — database-tier caching for PostGIS and SpatiaLite spatial queries
- Memory Limit Management — eviction policies, RSS monitoring, and GC tuning for containerised deployments
- Core Dashboard Architecture & State Management — session isolation, widget lifecycle, and data flow architecture