Probe WebGL in the browser before the map mounts, bridge the boolean back to Python through st.query_params, and conditionally render either an interactive PyDeck canvas or a cached contextily/matplotlib PNG — so the dashboard never shows a blank GPU canvas on VDI, headless CI, or locked-down browsers.

Jump to heading Why This Matters

Interactive map components such as PyDeck, hvPlot, and Datashander panes depend on the WebGL API for GPU-accelerated rendering. When that initialization fails, the user is left staring at a blank rectangle while the rest of the dashboard works fine — a particularly bad failure mode for a tool people use to make decisions. This page sits under Dynamic Spatial Filtering because a fallback is only useful if it keeps honouring the active bounding box filter: the static render must draw from the same clipped GeoDataFrame as the interactive one, so the analyst sees the same features regardless of which path runs. The detection result also belongs in session state so the probe runs once per session rather than on every rerun, and the broader component-swapping discipline is part of Spatial Component Integration & Interactive Maps.

WebGL initialization breaks in more deployment realities than most teams expect:

  • Corporate VDI/RDP environments with forced software rendering or disabled GPU passthrough.
  • Locked-down enterprise browsers (Chrome/Firefox ESR) whose policy blacklists specific GPU drivers.
  • Headless automation (Puppeteer, Playwright, GitHub Actions) launched without the --use-gl=desktop flag.
  • Mobile and low-memory devices that aggressively reclaim canvas contexts under memory pressure.

The Khronos Group WebGL Specification explicitly notes that context loss is expected under memory pressure or driver instability, which is why a proactive probe — rather than an after-the-fact error handler — is the right design.

Jump to heading How the Detection Bridge Works

Python runs server-side, so WebGL support can only be measured in the browser and then carried back across the iframe boundary. The flow below shows the single round-trip: a zero-size probe writes the result into the URL, Python reads it on the next rerun, and the conditional render picks a path.

WebGL detection bridge and conditional renderA vertical flow across two lanes. The browser lane probes canvas.getContext('webgl2') or 'webgl' and writes ?webgl=true or false into the parent URL with history.replaceState. A dashed boundary marks the iframe edge. The Python lane reruns, reads st.query_params, and any value other than the literal "true" — including the first-paint "unknown" — falls through to the safe default of false. The boolean then branches: true renders an interactive PyDeck ScatterplotLayer on the GPU; false renders a cached contextily and matplotlib PNG server-side. Both branches are fed by the same bounding-box-clipped GeoDataFrame.BROWSER (iframe)PYTHON (server)1 · Probe WebGLcanvas.getContext('webgl2') || 'webgl'2 · Write parent URLhistory.replaceState · ?webgl=true|falsererun3 · Read st.query_paramswebgl_param == "true"4 · Safe default"unknown" / anything else → Falsewebgl_supported?truefalseInteractive PyDeckScatterplotLayer · GPU canvasStatic fallback PNGcontextily + matplotlib · server-sidesame bounding-box-clipped GeoDataFrame

The key constraint is that a Streamlit component renders inside an iframe, so JavaScript injected with st.components.v1.html cannot assign directly to st.session_state. Using the page URL as the transport is the most reliable bridge that needs no custom component build.

Jump to heading Prerequisites

  • Streamlit 1.30+ (pip install "streamlit>=1.30") or Panel 1.3+ for the alternative below.
  • pydeck>=0.8, contextily>=1.4, matplotlib>=3.7, geopandas>=0.14, and pandas.
  • A working understanding of session state — the probe must run once per session, not on every rerun.

Jump to heading Step-by-Step Solution

Jump to heading Step 1 — Probe WebGL and bridge the result into Python

On the first run, inject a zero-size component that tests for a WebGL context and writes the boolean into the parent URL. Guard it behind a session_state flag so it fires exactly once, then st.rerun() to pick up the query param.

python
import streamlit as st

# Run the probe once per session, then re-read the URL on the next rerun.
if "webgl_checked" not in st.session_state:
    st.components.v1.html(
        """
        <script>
        (function () {
            var canvas = document.createElement('canvas');
            var gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
            var supported = gl ? 'true' : 'false';
            // The component runs in an iframe; write to the PARENT URL so
            // Streamlit can read it via st.query_params on the next rerun.
            var url = new URL(window.parent.location.href);
            url.searchParams.set('webgl', supported);
            window.parent.history.replaceState({}, '', url.toString());
        })();
        </script>
        """,
        height=0,
        width=0,
    )
    st.session_state.webgl_checked = True
    st.rerun()

# Safe default: anything other than an explicit "true" is treated as no WebGL.
webgl_param = st.query_params.get("webgl", "unknown")
webgl_supported = webgl_param == "true"

Jump to heading Step 2 — Filter the data once, before choosing a renderer

Apply the active bounding box to the GeoDataFrame before the WebGL branch so both paths draw the identical feature set. Here the data is clipped in EPSG:4326 to a lower-Manhattan window.

python
import geopandas as gpd
import pandas as pd
import numpy as np
from shapely.geometry import box

# Active viewport bounds (lon/lat, WGS 84). In production these come from
# the dropdown/map filters described in Dynamic Spatial Filtering.
WEST, SOUTH, EAST, NORTH = -74.02, 40.70, -73.93, 40.78
clip_box = box(WEST, SOUTH, EAST, NORTH)

np.random.seed(0)
points = gpd.GeoDataFrame(
    {"value": np.random.randint(10, 100, 400)},
    geometry=gpd.points_from_xy(
        np.random.uniform(-74.05, -73.90, 400),
        np.random.uniform(40.68, 40.82, 400),
    ),
    crs="EPSG:4326",
)

# Same clipped frame feeds both the interactive and the static renderer.
filtered = points.clip(clip_box)
df = pd.DataFrame(
    {
        "lon": filtered.geometry.x,
        "lat": filtered.geometry.y,
        "value": filtered["value"].values,
    }
)

Jump to heading Step 3 — Render the interactive PyDeck map when WebGL is available

python
import pydeck as pdk

if webgl_supported:
    st.subheader("Interactive map (WebGL)")
    deck = pdk.Deck(
        map_style="light",
        initial_view_state=pdk.ViewState(
            latitude=(SOUTH + NORTH) / 2,
            longitude=(WEST + EAST) / 2,
            zoom=11,
            pitch=40,
        ),
        layers=[
            pdk.Layer(
                "ScatterplotLayer",
                data=df,
                get_position=["lon", "lat"],
                get_fill_color="[255, 64, 64, 160]",
                get_radius=45,
                pickable=True,
            )
        ],
    )
    st.pydeck_chart(deck)

Jump to heading Step 4 — Render a cached static basemap when WebGL is missing

contextily fetches basemap tiles server-side, so it bypasses the browser GPU entirely. Wrap the render in st.cache_data keyed on the bounding box so the expensive tile fetch happens once per viewport — this is the same caching discipline used throughout Caching Strategies & Async Performance Tuning.

python
import io
import matplotlib.pyplot as plt
import contextily as ctx

@st.cache_data(show_spinner=False)
def render_static_map(df: pd.DataFrame, bounds: tuple) -> bytes:
    west, south, east, north = bounds
    fig, ax = plt.subplots(figsize=(8, 6))
    ax.scatter(df["lon"], df["lat"], c="#ff4040", alpha=0.7, s=28)
    ax.set_xlim(west, east)
    ax.set_ylim(south, north)
    ax.set_axis_off()

    # Tiles fetched server-side; crs matches the point CRS (EPSG:4326).
    ctx.add_basemap(ax, crs="EPSG:4326", source=ctx.providers.CartoDB.Positron)

    buf = io.BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight", dpi=150)
    plt.close(fig)
    return buf.getvalue()

if not webgl_supported:
    st.subheader("Static fallback map (WebGL unavailable)")
    png = render_static_map(df, (WEST, SOUTH, EAST, NORTH))
    st.image(png, use_container_width=True)

Jump to heading Verification

Force the fallback path locally without needing a broken GPU. Launch Chrome with software rendering disabled (or run headless), then confirm the static image renders instead of a blank canvas:

bash
# Reproduce a no-WebGL environment for a manual check.
google-chrome --disable-gpu --use-gl=disabled http://localhost:8501
python
# Unit-level check that the bridge defaults safely and the static render works.
assert st.query_params.get("webgl", "unknown") != "true" or webgl_supported

png = render_static_map(df, (WEST, SOUTH, EAST, NORTH))
assert png[:8] == b"\x89PNG\r\n\x1a\n", "fallback did not produce a valid PNG"
assert len(df) > 0, "bounding box clipped away every point — check CRS"

Expected behaviour: with a normal GPU you see the PyDeck scatter layer; with --disable-gpu the page shows the CartoDB Positron PNG with the same points, and the browser console logs no WebGL context lost error because no WebGL context was ever requested.

Jump to heading Edge Cases and Gotchas

  • The “unknown” first paint. Until the probe completes, webgl_param is "unknown", so webgl_supported is False and the static map renders first. This is deliberate — a static map is a better default than a blank canvas — but it means a one-frame flash of the fallback before the rerun swaps in PyDeck. Persist the result in session state to avoid re-probing on every interaction.
  • CRS mismatch between points and basemap. ctx.add_basemap(crs=...) must match the GeoDataFrame CRS. If your points are in a projected CRS such as EPSG:3857, pass that — passing EPSG:4326 against Web-Mercator tiles will offset every marker by hundreds of metres.
  • Cache key must include the bounds. If st.cache_data is keyed only on the DataFrame, a new viewport with the same point count returns a stale image. Include the bounding box tuple in the cache key as shown so each viewport rasterizes independently.

Jump to heading FAQ

Why not just catch the WebGL error on the client and re-render?

By the time a webglcontextlost event fires, the interactive component has already mounted and consumed memory, and the user has already seen the blank canvas. A proactive probe before mounting lets Python decide which component to render at all, so the failure is invisible rather than a visible flash of broken UI.

Does the static fallback support the same bounding-box filtering as the interactive map?

Yes. Filtering happens server-side on the GeoDataFrame before either renderer runs, so the PyDeck layer and the contextily PNG draw from the same clipped frame. The fallback loses interactivity, not the spatial query — pan and zoom go away, but the features shown are identical.

How do I keep the static PNG from slowing down every rerun?

Wrap the matplotlib render in st.cache_data keyed on the bounding box and point count. The slow part is the contextily tile fetch; once the rasterized buffer is cached, subsequent reruns that reuse the same viewport return in a few milliseconds. Down-sample with df.sample() if a single viewport ever holds more than a few thousand points.

Jump to heading Panel Alternative

Panel exposes URL query parameters through pn.state.location, so the same bridge works without a custom component. Inject the probe with pn.pane.HTML, write the result to the search params, and read it back after navigation:

python
import panel as pn

pn.extension()

webgl_probe = pn.pane.HTML(
    """
    <script>
    var gl = document.createElement('canvas').getContext('webgl2') ||
             document.createElement('canvas').getContext('webgl');
    var url = new URL(window.location.href);
    url.searchParams.set('webgl', gl ? 'true' : 'false');
    window.history.replaceState({}, '', url.toString());
    </script>
    """,
    height=0,
    width=0,
)

is_supported = pn.state.location.query_params.get("webgl", "false") == "true"
map_component = (
    pn.pane.Str("Interactive map renders here (WebGL)")
    if is_supported
    else pn.pane.Str("Static fallback map (no WebGL)")
)

pn.Column(webgl_probe, map_component).servable()

Back to Dynamic Spatial Filtering

Related