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"
Branch after the filter, not before itIn the upper pipeline the branch on WebGL support comes after the filtering stage, so a single filtered frame feeds both the interactive Deck.gl renderer and the cached static image. Whatever an analyst sees is the same set of features either way, and the fallback can be tested by flipping one boolean rather than by finding a machine without a GPU. In the lower pipeline the branch comes first, so each renderer carries its own filter code; the two implementations drift — one rounds the bounding box, the other clips by attribute first — and the fallback is exercised so rarely that the drift is discovered by a user on a locked-down laptop rather than by a test.FILTER ONCE, THEN BRANCHsource framefilter — one implementationpydeck — WebGL availablecontextily PNG — no WebGLthe same featureseither way, and thefallback is testable byflipping one booleanBRANCH FIRST — TWO FILTERS THAT DRIFTsource framefilter + pydeck — rounds the bboxfilter + PNG — clips attributes firstTwo code paths, one of which almost never runs — so the driftis found by a user on a locked-down laptop, months later,reporting that the map "shows different places". ### 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)
What the fallback still does, and what it honestly cannotTwo columns set out the fallback's contract. Retained: the same filtered features, the same extent and symbology, a legible basemap, and a PNG that can be downloaded or pasted into a report — which is more than the interactive map offers for that last purpose. Lost: panning and zooming, which now require a rerun; hover tooltips; click selection and therefore any workflow that starts from picking a feature; and per-feature styling changes that do not survive a cache key. Underneath, the cost that moved rather than disappeared: tiles are fetched server-side, so the container pays the network time and the memory for the rendered figure, and the cache key must include the bounding box or every rerun re-renders the same image.KEPTLOSTthe same filtered features, from the same code paththe correct extent and the correct symbologya legible basemap for spatial contexta PNG that pastes straight into a report —which the interactive map cannot give youenough to answer "where is this and how much of it"panning and zooming — each now costs a rerunhover tooltipsclick selection — and every workflow that beginsby picking a featureper-feature restyling between cache keysnot enough to answer "which one of these is it"And the cost that moved rather than vanishedTiles are fetched server-side now, so the container pays the network time and holds the rendered figure in memory.Key the cache on the bounding box or every rerun renders the identical image again — which turns a graceful fallbackinto the slowest page on the site for exactly the users who already had the worst hardware.

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