Debouncing Map Viewport Events in Streamlit
A two-second pan emits about ninety bounds events. Guard the expensive work on a timestamp in session state, keep the newest bounds rather than the first, and set the window near the query’s own latency — at which point the debounce is invisible.
Jump to heading Why this matters
Map components report their viewport continuously. A single unhurried pan across a city produces tens of bounds payloads, and in some Leaflet versions a stationary pointer hovering over the map produces them too. Bound naively to a spatial query, each one triggers a rerun, a bounding-box filter and a re-serialisation of the result — and every one of those except the last is thrown away before a human sees it.
The waste is not the interesting part. The interesting part is that the queries queue: they run in order on a single-threaded script, so by the time the ninetieth event arrives the dashboard is still working through the twentieth, and the map appears to lag several seconds behind the pointer. Users describe this as slowness. It is not slowness — the individual query is fast — it is ninety of them.
Jump to heading Prerequisites
streamlit>=1.36andstreamlit-folium>=0.20, or an equivalent component that returns viewport bounds.- The bounds-equality guard from syncing dropdown filters with map boundaries. Debouncing is the next layer on top of it: the equality guard removes events that changed nothing, and the debounce removes events that changed something you do not yet care about.
- A cached query function, so the events that do get through are cheap when they repeat.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Understand what a debounce can and cannot stop
The distinction matters because the most common implementation — a timestamp guard in the callback — does not stop the rerun. Streamlit re-executes the whole script whenever the component returns a new value, and no amount of guarding inside the script prevents that. What the guard prevents is the expensive part, which is the point; but if the script itself is slow for other reasons, a debounce will not fix it.
Jump to heading Step 2 — Guard on a timestamp, and keep the newest bounds
import time
import streamlit as st
DEBOUNCE_SECONDS = 0.3
def viewport_settled(fresh: tuple) -> bool:
"""True when the map has stopped moving long enough to be worth querying."""
st.session_state.pending_bbox = fresh # always keep the newest
now = time.monotonic()
last = st.session_state.get("last_query_at", 0.0)
if now - last < DEBOUNCE_SECONDS:
return False
st.session_state.last_query_at = now
return True
Storing pending_bbox on every call is what makes this a debounce rather than a throttle. A leading-edge throttle fires on the first event and discards the rest, which for a pan means querying the extent the map had when the drag started — the wrong answer, delivered promptly. Keeping the newest value means the query, whenever it runs, uses where the map actually is.
Jump to heading Step 3 — Wire it into the render path
result = st_folium(build_map(layer, st.session_state.bbox),
height=560, returned_objects=["bounds"])
if result and result.get("bounds"):
b = result["bounds"]
fresh = (round(b["_southWest"]["lng"], 4), round(b["_southWest"]["lat"], 4),
round(b["_northEast"]["lng"], 4), round(b["_northEast"]["lat"], 4))
if fresh != st.session_state.bbox and viewport_settled(fresh):
st.session_state.bbox = st.session_state.pending_bbox
st.rerun()
subset = query_viewport(st.session_state.bbox) # cached; see step 4
st.caption(f"{len(subset):,} features in view")
The two guards compose in a specific order: the equality check first, because it is free and removes the events that changed nothing at all, then the debounce, which is only asked about events that represent real movement.
Jump to heading Step 4 — Make the survivors cheap
Rounding the bounding box to four decimals before it becomes a cache key means that the handful of events that do survive the debounce during a slow pan mostly land on the same key, so they hit rather than re-query. This is the quantised cache key idea applied at the other end of the same problem.
@st.cache_data(ttl=600, max_entries=32, show_spinner=False)
def query_viewport(bbox: tuple) -> "gpd.GeoDataFrame":
return filter_by_bounds(load_layer(), *bbox)
The debounce bounds how many queries are issued; the quantised key bounds how many of those are actually computed. Neither alone is sufficient on a fast pan.
Jump to heading Step 5 — Choose the interval against the query, not against a habit
Jump to heading Verification
# 1. The window suppresses, and the newest bounds survive it.
st.session_state.last_query_at = time.monotonic()
assert viewport_settled((-0.2, 51.4, -0.1, 51.5)) is False, "fired inside the window"
assert st.session_state.pending_bbox == (-0.2, 51.4, -0.1, 51.5), "newest bounds lost"
# 2. After the window it fires, with the latest value rather than the first.
time.sleep(DEBOUNCE_SECONDS + 0.05)
assert viewport_settled((-0.3, 51.3, -0.05, 51.6)) is True
assert st.session_state.pending_bbox == (-0.3, 51.3, -0.05, 51.6)
Then measure it in the browser. Open the network panel, pan for two seconds, and count the requests: the target is one or two, not ninety. Counting is more reliable than watching, because a dashboard that issues ninety fast queries can look acceptable on a quiet local database and fall over on production data.
Jump to heading Edge cases and gotchas
time.time()versustime.monotonic(). Use the monotonic clock. A wall-clock jump from an NTP correction can make the guard think a query ran in the future and suppress every subsequent event until the clock catches up.- The debounce state is per session. Storing
last_query_atin a module global rather than in session state makes two concurrent analysts debounce each other, so one person’s panning suppresses another person’s queries. - Very slow queries need a different tool. If the query takes several seconds, no debounce interval is comfortable — the fix is to make the query fast, usually with a spatial index or a coarser simplification tier, and to debounce afterwards.
- A trailing-only debounce feels unresponsive. Nothing happens for the whole window after the gesture ends. Adding a cheap immediate acknowledgement — a spinner, a dimmed layer, a caption that says “updating” — costs nothing and removes the impression of lag entirely.
Jump to heading FAQ
Is throttling the same as debouncing here?
No, and the difference matters for a viewport. A throttle fires at a fixed maximum rate, so during a two-second pan at a 300 ms throttle you issue six queries, five of which are for extents the map has already left. A debounce fires once the events stop, so you issue one, for the extent the map settled on. For a viewport you almost always want the debounce.
Can I debounce inside the component instead?
If the component exposes it, yes, and it is the cheapest option because the event never crosses into Python at all. The reason not to rely on it alone is that you do not control the component’s behaviour across versions, and a debounce that quietly stops working after an upgrade fails in exactly the way that is hardest to notice.
Why does the script still run on every event?
Because Streamlit reruns whenever a component returns a new value, and the guard lives inside the script rather than above it. That is acceptable as long as everything expensive in the script is behind the guard or behind a cache — and it is a good reason to keep the top of a page cheap, since it is executed far more often than it appears.
Back to Widget Lifecycle Management.