Using asyncio for Concurrent Map Tile Loading in Python
Replace sequential tile fetches with asyncio and aiohttp to load a full ZXY raster viewport in roughly the time of a single request — instead of multiplying that latency by every tile in the grid.
Jump to heading Why this matters
Map tile servers return small, independent image files — typically 256 × 256 PNG or WebP. Network latency dominates the fetch time, not CPU decoding. At zoom levels 10–14 a single viewport commonly demands 50–200 tiles. Blocking HTTP requests compound that latency linearly: 100 tiles at 150 ms each equals 15 seconds of frozen UI. Concurrent I/O collapses those waits so the total duration is roughly the slowest single request plus connection-setup overhead.
Concurrent tile fetching is one of the core Async Data Loading Patterns that keep a spatial dashboard responsive, and it sits inside the wider discipline of Caching Strategies & Async Performance Tuning. Understanding how the event loop schedules tile fetches also clarifies when to apply a semaphore, when to back the fetch with a disk cache, and how to bridge async results into Streamlit or Panel without blocking the UI thread.
Jump to heading Prerequisites
- Python 3.9+ —
asyncio.to_thread()(used in the Streamlit bridge below) requires 3.9;asyncio.TaskGroup(an alternative for fine-grained cancellation) requires 3.11. aiohttp>=3.9— provides the async HTTP client andTCPConnectorfor connection pooling.Pillow>=10andnumpy>=1.24— decode raw PNG/WebP bytes into arrays for spatial compositing.- Conceptual familiarity with the Python event loop and coroutines (see Async Data Loading Patterns for a primer).
Install the runtime dependencies:
pip install aiohttp Pillow numpy
Jump to heading Step-by-step solution
Jump to heading Step 1 — Write the single-tile coroutine
The coroutine acquires a semaphore slot before opening the HTTP connection. This is the critical ordering: acquire first, then connect, so the semaphore controls active socket count rather than merely queued requests.
import asyncio
import aiohttp
import numpy as np
from io import BytesIO
from PIL import Image
from typing import Optional
async def fetch_tile(
session: aiohttp.ClientSession,
semaphore: asyncio.Semaphore,
z: int,
x: int,
y: int,
url_template: str,
) -> Optional[np.ndarray]:
"""Fetch one ZXY tile and return a decoded NumPy array, or None on error."""
url = url_template.format(z=z, x=x, y=y)
async with semaphore:
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=10)
) as resp:
if resp.status == 200:
raw = await resp.read()
return np.array(Image.open(BytesIO(raw)))
if resp.status == 404:
return None # ocean tiles or missing zoom level
resp.raise_for_status() # propagate 429 / 5xx
except (aiohttp.ClientError, asyncio.TimeoutError):
return None
return_exceptions=False is intentionally omitted here; the try/except inside each coroutine absorbs transient failures and returns None for missing tiles so one bad tile never cancels the entire grid fetch.
Jump to heading Step 2 — Build the concurrent grid loader
from typing import Dict, Tuple
async def load_tile_grid(
zoom: int,
x_range: Tuple[int, int],
y_range: Tuple[int, int],
tile_url_template: str,
max_concurrent: int = 15,
) -> Dict[Tuple[int, int], Optional[np.ndarray]]:
"""
Fetch a rectangular tile grid concurrently.
Returns a dict mapping (x, y) -> NumPy array (or None for missing tiles).
"""
coords = [
(x, y)
for x in range(x_range[0], x_range[1] + 1)
for y in range(y_range[0], y_range[1] + 1)
]
semaphore = asyncio.Semaphore(max_concurrent)
# limit=30 prevents file-descriptor exhaustion; ttl_dns_cache avoids
# repeated DNS lookups across hundreds of tile requests.
connector = aiohttp.TCPConnector(limit=30, ttl_dns_cache=300)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [
fetch_tile(session, semaphore, zoom, x, y, tile_url_template)
for x, y in coords
]
results = await asyncio.gather(*tasks)
return dict(zip(coords, results))
The dictionary return type preserves spatial coordinates, which simplifies downstream stitching with rasterio or datashader — you can reconstruct the tile mosaic by sorting keys without losing the ZXY metadata.
Jump to heading Step 3 — Bridge to Streamlit
Streamlit runs inside a synchronous WSGI context. Calling await directly in a widget callback raises RuntimeError: no running event loop. The cleanest bridge is to wrap the coroutine in a cached synchronous function so the async work runs once per TTL rather than on every rerun.
import streamlit as st
import asyncio
@st.cache_data(ttl=3600, show_spinner="Fetching tile grid…")
def get_tile_grid(zoom: int, x_min: int, x_max: int, y_min: int, y_max: int, template: str):
"""Synchronous wrapper — safe to call from any Streamlit callback."""
return asyncio.run(
load_tile_grid(zoom, (x_min, x_max), (y_min, y_max), template)
)
# Example: load zoom 12, a 6×6 tile block over central Europe
tiles = get_tile_grid(
zoom=12,
x_min=2144, x_max=2149,
y_min=1412, y_max=1417,
template="https://tile.openstreetmap.org/{z}/{x}/{y}.png",
)
st.write(f"Loaded {sum(v is not None for v in tiles.values())} of {len(tiles)} tiles")
For Panel, bind the coroutine directly to pn.state.onload() or use pn.io.with_lock() for thread-safe state updates during background prefetch.
Jump to heading Step 4 — Add a two-tier disk cache for historical zoom levels
For dashboards that let users navigate across zoom levels or export map images, a memory-only cache wastes repeated fetches across sessions. Pair in-memory results with a diskcache layer keyed by (z, x, y):
import diskcache
_tile_cache = diskcache.Cache("/tmp/tile_cache", size_limit=2 ** 30) # 1 GB
async def fetch_tile_cached(
session: aiohttp.ClientSession,
semaphore: asyncio.Semaphore,
z: int, x: int, y: int,
url_template: str,
) -> Optional[np.ndarray]:
key = (z, x, y)
if key in _tile_cache:
return _tile_cache[key]
result = await fetch_tile(session, semaphore, z, x, y, url_template)
if result is not None:
_tile_cache.set(key, result, expire=86400) # 24-hour TTL
return result
This is the disk-persistence layer that aligns with the eviction-policy guidance in Query Result Caching.
Jump to heading Verification
After running get_tile_grid(), assert that returned count matches expected geometry and measure wall-clock time:
import time
start = time.perf_counter()
tiles = asyncio.run(
load_tile_grid(12, (2144, 2149), (1412, 1417), "https://tile.openstreetmap.org/{z}/{x}/{y}.png")
)
elapsed = time.perf_counter() - start
expected = (2149 - 2144 + 1) * (1417 - 1412 + 1) # 6 × 6 = 36
loaded = sum(v is not None for v in tiles.values())
assert loaded > 0, "No tiles returned — check URL template and network access"
assert len(tiles) == expected, f"Expected {expected} entries, got {len(tiles)}"
print(f"Fetched {loaded}/{expected} tiles in {elapsed:.2f}s")
# Expected output: Fetched 36/36 tiles in 0.3–0.8s (vs ~5.4s synchronously)
A successful run returns wall-clock times well under 1 second for a 6 × 6 grid on a typical broadband connection. If elapsed time exceeds 3 seconds, the semaphore value is likely too low or the tile server is rate-limiting — see the edge cases below.
Jump to heading Edge cases and gotchas
asyncio.run()inside Jupyter or an existing event loop: Callingasyncio.run()from a Jupyter cell raisesRuntimeError: This event loop is already running. Useawait load_tile_grid(...)directly in a cell, or installnest_asyncioand callnest_asyncio.apply()at the top of the notebook.- Tile server
User-Agentrequirements: OpenStreetMap’s usage policy requires a descriptiveUser-Agentheader. Passheaders={"User-Agent": "MyDashboard/1.0 ([email protected])"}toaiohttp.ClientSession. Missing this causes intermittent403 Forbiddenresponses that surface asNoneentries and are hard to distinguish from legitimate empty tiles. - CRS mismatch between tile coordinates and your spatial data: ZXY tiles use Web Mercator (EPSG:3857). If your GeoDataFrame or bounding box is in WGS 84 (EPSG:4326), convert tile bounds with
pyproj.Transformerbefore computingx_rangeandy_range. Skipping this step yields a spatially misaligned mosaic that appears correct until overlaid on vector data. - Memory pressure at high zoom levels: A 10 × 10 tile grid at zoom 15 returns 100 arrays of roughly 256 × 256 × 4 bytes = ~26 MB uncompressed. At zoom 18 the same geographic area requires a 32 × 32 grid (roughly 268 MB). Apply the
max_concurrentand disk-cache limits described in Memory Limit Management to prevent OOM kills on constrained dashboard servers.
Jump to heading FAQ
Why does asyncio.run() raise a RuntimeError inside a Streamlit callback?
Streamlit’s WSGI server may have a running event loop in the current thread. Calling asyncio.run() attempts to create a second loop, which Python forbids. Wrap the coroutine in a synchronous function at module level (as shown in Step 3), or retrieve the existing loop with asyncio.get_event_loop() and call loop.run_until_complete(coroutine) instead.
How do I pick the right semaphore value for my tile server?
Start at 10 for public providers such as OpenStreetMap or Stadia Maps and check their terms of service for a stated connection cap. For self-hosted GeoServer or MapProxy instances, benchmark at 20–50 while monitoring CPU and connection-pool utilisation. Setting the semaphore too high triggers 429 Too Many Requests from rate-limited public servers; too low leaves concurrency gains on the table.
Can I use httpx instead of aiohttp?
Yes. Replace aiohttp.ClientSession with httpx.AsyncClient — the asyncio.Semaphore and asyncio.gather() patterns are identical. httpx has a stricter default timeout API and is a better choice if your project already uses it for other async HTTP work, since maintaining two HTTP clients adds dependency overhead for no performance benefit.
Back to Async Data Loading Patterns
Related
- Async Data Loading Patterns — broader guide to non-blocking spatial data retrieval in Python dashboards
- Query Result Caching — cache-key design and eviction strategies for spatial query results
- Memory Limit Management — controlling memory footprint when loading large tile grids or GeoDataFrames
- Caching Strategies & Async Performance Tuning — parent section covering the full performance optimization stack