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.

Sequential versus concurrent tile fetch timelinesThe top timeline shows blocking requests where each tile fetch waits for the previous one to finish, so six tiles take six request-durations end to end. The bottom timeline shows asyncio dispatching all fetches at once; their request bars overlap, so wall-clock time is roughly a single request duration. The same six tiles finish far sooner.Sequential (blocking)each fetch waits for the previous to returntile 1tile 2tile 3tile 4tile 5tile 6≈ 6 × 150 ms = 900 ms for 6 tilesConcurrent (asyncio.gather)all fetches dispatched at once, waits overlaptile 1tile 2tile 3–6≈ 150 ms total — bounded by the slowest request

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.


Concurrent tile loading with asyncioasyncio.gather fans out one coroutine per ZXY coordinate. The coroutines pass through an asyncio.Semaphore that caps how many open HTTP connections hit the tile server at once. Each coroutine sends an HTTP GET to the ZXY tile endpoint and decodes the PNG response into a NumPy array. Results are gathered back into a dict keyed by (x, y) coordinate and handed to the dashboard renderer.Fan-outasyncio.gatherone task per (x, y)N coroutinesThrottleSemaphore(15)≤15 open socketsFetch & decodeZXY tile serverGET /{z}/{x}/{y}.pngResult dict(x, y) → ndarraydecode PNG → NumPyDashboardrenderer

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 and TCPConnector for connection pooling.
  • Pillow>=10 and numpy>=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:

bash
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.

python
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

python
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.

python
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):

python
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:

python
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: Calling asyncio.run() from a Jupyter cell raises RuntimeError: This event loop is already running. Use await load_tile_grid(...) directly in a cell, or install nest_asyncio and call nest_asyncio.apply() at the top of the notebook.
  • Tile server User-Agent requirements: OpenStreetMap’s usage policy requires a descriptive User-Agent header. Pass headers={"User-Agent": "MyDashboard/1.0 ([email protected])"} to aiohttp.ClientSession. Missing this causes intermittent 403 Forbidden responses that surface as None entries 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.Transformer before computing x_range and y_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_concurrent and 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