Implementing Fallback Static Maps When WebGL Fails
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=desktopflag. - 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.
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, andpandas.- 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.
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.
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
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.
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:
# Reproduce a no-WebGL environment for a manual check.
google-chrome --disable-gpu --use-gl=disabled http://localhost:8501
# 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_paramis"unknown", sowebgl_supportedisFalseand 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 asEPSG:3857, pass that — passingEPSG:4326against Web-Mercator tiles will offset every marker by hundreds of metres. - Cache key must include the bounds. If
st.cache_datais 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:
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
- Dynamic Spatial Filtering — the parent workflow that supplies the bounding box both renderers honour
- Syncing DeckGL Layers with Streamlit State Variables — keeping the WebGL layer in sync with server-side state
- Handling Large GeoJSON Files in Leafmap Without Browser Lag — another client-side rendering limit and its server-side workaround
- Session State Patterns — caching the WebGL probe result so it runs once per session