Async Data Loading Patterns for Spatial Dashboards
Spatial analytics dashboards routinely ingest multi-megabyte GeoJSON payloads, high-resolution raster tiles, and complex vector queries from remote OGC-compliant endpoints. When these operations run synchronously on the main execution thread, they block UI rendering, trigger session state timeouts, and degrade the interactive experience for every concurrent user. Implementing robust async data loading patterns decouples I/O-bound spatial retrieval from the presentation layer, enabling concurrent network requests, predictable memory footprints, and responsive map interactions without freezing the dashboard.
Treat this as one system to master end to end: isolating blocking calls, bounding concurrency, bridging to synchronous frameworks, streaming large payloads, and handing results off to query result caching for session-level reuse. Each step below builds on the previous one, and the patterns assume the session state and widget lifecycle management behaviour described elsewhere on this site. For the architectural context that ties all of these patterns together, see Caching Strategies & Async Performance Tuning.
Jump to heading The Problem: Synchronous I/O in an Inherently Concurrent Context
A typical spatial dashboard fetches three to ten data layers on startup: administrative boundaries (WGS 84 / EPSG:4326), a raster hillshade (EPSG:3857), real-time sensor readings, and one or more filtered feature collections. Fetching these in sequence on the main thread produces cumulative latency — if each layer takes two seconds, ten layers produce a twenty-second cold start. Worse, Streamlit re-runs the entire script on every widget interaction, so a blocking fetch inside the render loop re-fires on every slider change or dropdown selection.
The damage compounds under multi-user load. Each concurrent user runs a separate Python thread inside the Streamlit server, and any thread blocked on network I/O consumes a thread-pool slot, limits overall server throughput, and risks connection-pool exhaustion in the HTTP client. The async patterns below address all three failure modes simultaneously.
Jump to heading Architecture: From HTTP Request to Rendered Map Layer
The diagram below shows the full async data loading pipeline for a spatial dashboard, from the moment a user triggers a data refresh to the point where a GeoDataFrame lands in the cache layer and drives a rendered map.
Jump to heading Prerequisites
Before implementing concurrent spatial data loaders, confirm your environment meets these requirements:
- Python 3.9+ — needed for
asyncio.to_thread()(3.9) andasyncio.TaskGroup(3.11+). aiohttp >= 3.9orhttpx >= 0.27(async mode) for non-blocking spatial API requests.geopandas >= 0.14andrasterio >= 1.3for post-retrieval parsing and CRS transformations.- Streamlit 1.20+ or Panel 1.3+ — both support async callbacks when bridged correctly.
- Familiarity with Python’s single-threaded event loop model; if you are new to coroutines, skim the Python asyncio documentation before proceeding.
- Understanding of how widget lifecycle management in Panel and Streamlit affects when callbacks fire — async fetches triggered mid-render can produce duplicate calls without proper guards.
Jump to heading Core Implementation Workflow
Jump to heading Step 1 — Isolate I/O-Bound Operations
Map your data pipeline and mark every call that waits on network or disk: HTTP requests to OGC Feature API endpoints, WMS/WCS tile downloads, PostGIS queries via asyncpg, and cloud object reads via aiobotocore. Leave CPU-intensive steps — spatial joins, topology validation, CRS reprojection via pyproj — synchronous or in a ThreadPoolExecutor. Mixing CPU-heavy geometry operations inside an async event loop starves other coroutines.
# Identify blocking vs async-safe operations
ASYNC_SAFE = ["aiohttp.get", "asyncpg.fetch", "aiobotocore.get_object"]
SYNC_ONLY = ["geopandas.sjoin", "pyproj.Transformer.transform", "rasterio.warp.reproject"]
Align fetch boundaries with logical data partitions. If you plan to hand results off to query result caching downstream, fetch one logical layer per coroutine — this gives the cache layer a clean, hashable key per dataset.
Jump to heading Step 2 — Configure Bounded Concurrency
Use asyncio.Semaphore to cap simultaneous outbound connections. Without a bound, asyncio.gather() over fifty tile URLs fires all fifty requests simultaneously, exhausting the local socket pool and triggering rate-limit 429 responses on most OGC servers. The semaphore acts as a gate: at most max_concurrent coroutines hold a permit and reach the network at once, while the remainder wait in line and slot in as each in-flight request completes.
import asyncio
import aiohttp
async def fetch_with_bound(urls: list[str], max_concurrent: int = 5) -> list[bytes]:
sem = asyncio.Semaphore(max_concurrent)
async def _guarded_get(session: aiohttp.ClientSession, url: str) -> bytes:
async with sem:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
resp.raise_for_status()
return await resp.read()
async with aiohttp.ClientSession() as session:
tasks = [_guarded_get(session, u) for u in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
Production tuning: start at max_concurrent=5, measure p95 latency under load, and raise the limit in increments of five until you observe degraded error rates. For map tile loading workflows, the detailed guide on using asyncio for concurrent map tile loading in Python covers per-tile-server optimal concurrency values and CDN-aware request patterns.
Jump to heading Step 3 — Bridge Async Results to the UI Framework
Streamlit and Panel both execute the dashboard script (or callback) on a synchronous Python thread without a running event loop. Calling await directly inside a Streamlit function body raises RuntimeError: no running event loop. There are two correct bridging strategies:
Strategy A — Dedicated event loop per call (simple, stateless):
import asyncio
from typing import Callable, Any
def run_async(coro_fn: Callable, *args, **kwargs) -> Any:
"""Run an async coroutine from a synchronous Streamlit callback."""
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro_fn(*args, **kwargs))
finally:
loop.close()
# In your Streamlit page:
import streamlit as st
gdfs = run_async(fetch_and_parse, urls=LAYER_URLS)
Strategy B — Persistent background loop (lower overhead for frequent calls):
import asyncio
import threading
_BG_LOOP: asyncio.AbstractEventLoop | None = None
_BG_THREAD: threading.Thread | None = None
def _ensure_bg_loop() -> asyncio.AbstractEventLoop:
global _BG_LOOP, _BG_THREAD
if _BG_LOOP is None or not _BG_LOOP.is_running():
_BG_LOOP = asyncio.new_event_loop()
_BG_THREAD = threading.Thread(target=_BG_LOOP.run_forever, daemon=True)
_BG_THREAD.start()
return _BG_LOOP
def dispatch_async(coro):
"""Submit a coroutine to the shared background loop and block until done."""
loop = _ensure_bg_loop()
future = asyncio.run_coroutine_threadsafe(coro, loop)
return future.result(timeout=60)
Strategy B is preferred when your dashboard makes many small async calls (e.g. per-user tile fetches on viewport pan) because it avoids creating a new event loop on every widget interaction.
Jump to heading Step 4 — Stream and Parse Payloads Efficiently
Avoid materialising the full HTTP response in memory before parsing. For GeoJSON and FlatGeobuf, use aiohttp’s response.content.iter_chunked() to feed bytes incrementally into an io.BytesIO buffer:
import io
import geopandas as gpd
import aiohttp
async def fetch_geojson(session: aiohttp.ClientSession, url: str) -> gpd.GeoDataFrame:
"""Stream GeoJSON bytes directly into geopandas without full materialisation."""
buf = io.BytesIO()
async with session.get(url) as resp:
resp.raise_for_status()
async for chunk in resp.content.iter_chunked(65536): # 64 KiB chunks
buf.write(chunk)
buf.seek(0)
# geopandas accepts a file-like object; no intermediate string decode needed
return gpd.read_file(buf, engine="pyogrio")
For raster payloads (GeoTIFF, Cloud-Optimised GeoTIFF), use rasterio.open() with a MemoryFile:
import rasterio
from rasterio.io import MemoryFile
async def fetch_cog(session: aiohttp.ClientSession, url: str) -> rasterio.DatasetReader:
"""Load a Cloud-Optimised GeoTIFF into rasterio via async HTTP."""
buf = io.BytesIO()
async with session.get(url) as resp:
resp.raise_for_status()
async for chunk in resp.content.iter_chunked(131072): # 128 KiB
buf.write(chunk)
buf.seek(0)
mem = MemoryFile(buf.read())
return mem.open()
This approach keeps memory usage proportional to a single layer rather than the sum of all in-flight requests. It pairs naturally with the memory limit management strategies that cap per-session RSS.
Jump to heading Step 5 — Apply Downstream Caching
Store parsed spatial objects with framework-specific cache decorators after parsing and CRS validation. Caching raw bytes wastes serialisation overhead and bypasses schema-level invalidation. When using Streamlit, follow the @st.cache_data implementation patterns to ensure your GeoDataFrame is hashable, the TTL aligns with upstream data freshness, and memory pressure from large geometry columns is controlled.
import streamlit as st
import geopandas as gpd
@st.cache_data(ttl=600, show_spinner="Loading boundary data…")
def load_boundaries(region_code: str) -> gpd.GeoDataFrame:
"""Cached wrapper: async fetch + parse, result stored in Streamlit's cache."""
urls = [
f"https://api.example.org/ogc/collections/boundaries/items"
f"?filter=region='{region_code}'&f=json&crs=EPSG:4326"
]
raw = run_async(fetch_with_bound, urls, max_concurrent=3)
frames = [gpd.read_file(io.BytesIO(b)) for b in raw if isinstance(b, bytes)]
if not frames:
return gpd.GeoDataFrame()
gdf = gpd.pd.concat(frames, ignore_index=True)
return gdf.to_crs(epsg=4326)
Jump to heading Advanced Patterns
Jump to heading Exponential Backoff with Jitter for Spatial Endpoints
OGC API servers and commercial tile CDNs impose per-IP rate limits that produce intermittent 429 Too Many Requests or 503 Service Unavailable responses. Bare asyncio.gather() with return_exceptions=True surfaces these as exception objects but does not retry. Add an async retry loop with jitter:
import asyncio
import random
import aiohttp
async def fetch_with_retry(
session: aiohttp.ClientSession,
url: str,
max_attempts: int = 4,
base_delay: float = 0.5,
) -> bytes:
for attempt in range(max_attempts):
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=45)) as resp:
if resp.status in (429, 503):
retry_after = float(resp.headers.get("Retry-After", base_delay * 2**attempt))
jitter = random.uniform(0, retry_after * 0.3)
await asyncio.sleep(retry_after + jitter)
continue
resp.raise_for_status()
return await resp.read()
except aiohttp.ClientError as exc:
if attempt == max_attempts - 1:
raise
delay = base_delay * 2**attempt + random.uniform(0, 0.3)
await asyncio.sleep(delay)
raise RuntimeError(f"Exhausted {max_attempts} retries for {url}")
Jump to heading Priority Queues for Multi-Layer Dashboards
When a dashboard loads ten or more layers, not all are equally critical. Use asyncio.PriorityQueue to fetch high-priority layers (current viewport boundary, active filter features) before secondary context layers (administrative background, terrain hillshade):
import asyncio
import aiohttp
import geopandas as gpd
import io
from dataclasses import dataclass, field
from typing import Any
@dataclass(order=True)
class LayerTask:
priority: int # lower = higher priority
url: str = field(compare=False)
layer_id: str = field(compare=False)
async def priority_fetch(tasks: list[LayerTask], max_concurrent: int = 6) -> dict[str, gpd.GeoDataFrame]:
queue: asyncio.PriorityQueue = asyncio.PriorityQueue()
for t in tasks:
await queue.put(t)
sem = asyncio.Semaphore(max_concurrent)
results: dict[str, gpd.GeoDataFrame] = {}
async def worker(session: aiohttp.ClientSession):
while not queue.empty():
task = await queue.get()
async with sem:
try:
async with session.get(task.url) as resp:
resp.raise_for_status()
data = await resp.read()
results[task.layer_id] = gpd.read_file(io.BytesIO(data))
except Exception as exc:
results[task.layer_id] = exc # type: ignore[assignment]
finally:
queue.task_done()
async with aiohttp.ClientSession() as session:
workers = [asyncio.create_task(worker(session)) for _ in range(max_concurrent)]
await queue.join()
for w in workers:
w.cancel()
return results
Jump to heading Lazy Per-Viewport Fetching with Bounding Box Filters
Rather than fetching full feature collections on startup, defer secondary layers until the user pans or zooms to a new viewport. Combine an OGC API — Features bbox parameter with a debounced async trigger:
import asyncio
import aiohttp
import geopandas as gpd
import io
_VIEWPORT_TASK: asyncio.Task | None = None
async def _fetch_viewport_features(
bbox: tuple[float, float, float, float], # (minx, miny, maxx, maxy) in EPSG:4326
collection: str = "urban-boundaries",
endpoint: str = "https://api.example.org/ogc",
) -> gpd.GeoDataFrame:
url = (
f"{endpoint}/collections/{collection}/items"
f"?bbox={','.join(str(v) for v in bbox)}&f=json&limit=500"
)
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=20)) as resp:
resp.raise_for_status()
data = await resp.read()
return gpd.read_file(io.BytesIO(data)).set_crs(epsg=4326)
def trigger_viewport_fetch(bbox: tuple[float, float, float, float]) -> gpd.GeoDataFrame:
"""Cancel any in-flight viewport request and start a new one (debounce pattern)."""
global _VIEWPORT_TASK
loop = _ensure_bg_loop()
if _VIEWPORT_TASK and not _VIEWPORT_TASK.done():
_VIEWPORT_TASK.cancel()
coro = _fetch_viewport_features(bbox)
future = asyncio.run_coroutine_threadsafe(coro, loop)
return future.result(timeout=30)
This pattern feeds naturally into dynamic spatial filtering workflows where viewport changes drive both the fetch and the visible feature set.
Jump to heading Verification & Testing
Use pytest-asyncio to test coroutines directly without a full dashboard launch:
import pytest
import pytest_asyncio
import aiohttp
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_semaphore_bounds_concurrency():
"""Confirm no more than max_concurrent requests are live simultaneously."""
active = 0
peak = 0
async def fake_get(url: str) -> bytes:
nonlocal active, peak
active += 1
peak = max(peak, active)
await asyncio.sleep(0.05) # simulate network latency
active -= 1
return b'{"type":"FeatureCollection","features":[]}'
with patch("aiohttp.ClientSession.get", side_effect=fake_get):
urls = [f"https://api.test/layer{i}" for i in range(20)]
await fetch_with_bound(urls, max_concurrent=5)
assert peak <= 5, f"Peak concurrency {peak} exceeded semaphore limit of 5"
Profile memory during fetch-and-parse under realistic payload sizes using tracemalloc:
import tracemalloc
import asyncio
tracemalloc.start()
snapshot_before = tracemalloc.take_snapshot()
gdfs = run_async(fetch_and_parse, urls=["https://api.example.org/large-layer.geojson"])
snapshot_after = tracemalloc.take_snapshot()
top = snapshot_after.compare_to(snapshot_before, "lineno")
for stat in top[:5]:
print(stat) # look for unexpected full-response allocations
Confirm CRS integrity on every parsed frame before handing to the map renderer:
import geopandas as gpd
def assert_crs(gdf: gpd.GeoDataFrame, expected_epsg: int = 4326) -> None:
assert gdf.crs is not None, "GeoDataFrame has no CRS — reprojection will fail"
assert gdf.crs.to_epsg() == expected_epsg, (
f"Expected EPSG:{expected_epsg}, got EPSG:{gdf.crs.to_epsg()}"
)
Jump to heading Troubleshooting
RuntimeError: no running event loop : Raised when await appears in a synchronous Streamlit callback or a Panel param.watch handler. Fix: wrap the entire async call in run_async() (Strategy A above) or dispatch it to the shared background loop (Strategy B). Never mix asyncio.run() inside an already-running loop.
aiohttp.ServerTimeoutError: Connection timeout : The endpoint did not respond within ClientTimeout.total. Spatial APIs returning large feature collections often need 60–120 seconds. Separate connect and read timeouts: ClientTimeout(connect=5, sock_read=90) prevents false timeouts on slow-starting connections while still catching stalled transfers.
asyncio.CancelledError propagating to UI : Occurs when the dashboard re-runs (user widget change) while a background coroutine is still in flight. The prior task is cancelled but the exception surfaces in the new run. Guard with try/except asyncio.CancelledError: return inside long-running coroutines, and always call task.cancel() before launching a replacement.
MemoryError during pd.concat() of fetched frames : Ten 50 MB GeoJSON payloads produce 500 MB of GeoDataFrame before concat overhead. Strategies: (a) filter server-side with bbox and filter OGC API parameters before fetching; (b) stream parse into pyarrow and concat lazily; © read directly from cloud storage via signed URL in rasterio.open() without materialising the file locally. See memory limit management for per-session RSS caps and eviction strategies.
KeyError on cached result after schema change : @st.cache_data uses the function arguments as the cache key. If an upstream API adds a column, the cached frame is stale but the key does not change. Add a schema_version argument to the cached function and increment it on upstream schema changes to force invalidation.
Jump to heading Performance Considerations
| Scenario | Recommended pattern | Notes |
|---|---|---|
| < 5 MB total payload | Serial async with single asyncio.gather() | Overhead of semaphore not worth it at this scale |
| 5–50 MB, 3–10 endpoints | Semaphore(5) + iter_chunked(65536) | Sweet spot for most production dashboards |
| > 50 MB or > 20 endpoints | Priority queue + viewport-based lazy fetch | Avoid startup fetches; defer until viewport is known |
| Frequently repeated identical requests | Add @st.cache_data(ttl=300) wrapper | Async fetch runs once; subsequent calls hit cache in microseconds |
| High-cardinality user concurrency | Strategy B (persistent background loop) | Avoids per-user event loop creation overhead |
Async concurrency provides the biggest gains when network latency dominates. For local PostGIS queries under 10 ms, asyncpg async still helps — but the speedup comes from pipelining multiple queries, not from overlapping network wait times. For CPU-bound operations, asyncio.to_thread() and ProcessPoolExecutor are the correct tools; the event loop alone will not parallelise GIL-bound Python.
Cache key design is the other major performance lever. Ensure your cache function arguments uniquely identify the spatial extent, CRS, attribute filter, and schema version — coarse keys cause unnecessary cache misses; overly fine keys prevent any sharing across users hitting the same layer.
Jump to heading FAQ
Why does await inside a Streamlit callback raise RuntimeError?
Streamlit’s execution model is synchronous. Each script re-run happens on a fresh thread without an active event loop. Calling await directly in a callback raises RuntimeError: no running event loop. Wrap async logic in asyncio.run() inside a ThreadPoolExecutor, or use asyncio.run_coroutine_threadsafe() to dispatch to a persistent background loop (see Strategy B in Step 3 above).
How many concurrent requests should I allow against OGC API endpoints?
Start with a Semaphore of 5 and increase only after measuring observed latency and error rates. Most public OGC Feature API servers enforce per-IP rate limits between 5 and 20 req/s. Private deployments behind a CDN can usually tolerate 10–20 concurrent connections. The detailed concurrency tuning guide for map tile workloads is in using asyncio for concurrent map tile loading in Python.
Should I cache raw HTTP bytes or parsed GeoDataFrames?
Always cache the parsed GeoDataFrame, not the raw bytes. Cached bytes cannot be hashed for TTL validation and force re-parsing on every cache hit. Caching after parsing also lets @st.cache_data track column schema changes and invalidate stale frames automatically. The @st.cache_data implementation page covers hash function customisation for geometry columns.
Back to Caching Strategies & Async Performance Tuning
Related
- Using asyncio for Concurrent Map Tile Loading in Python — the focused walkthrough for tile-server concurrency tuning
- Query Result Caching — where parsed
GeoDataFrameresults land after an async fetch - @st.cache_data Implementation — hashing geometry columns and TTL design for cached frames
- Memory Limit Management — per-session RSS caps that bound in-flight payload size
- Dynamic Spatial Filtering — viewport changes that drive the lazy per-bbox fetch